diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index e4bed54a7f8..4dd92e9d0a1 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -39,12 +39,14 @@ permissions: {} jobs: # If called with version_type 'canary' or 'stable', build + publish to NPM - # If called with version_type 'nightly', just tag the given version with nightly tag. It was already published by the canary build. + # If called with version_type 'nightly', do nothing (we're not yet tagging them with the nightly tag) publish: name: Publish NPM packages runs-on: github-hosted-ubuntu-x64-small if: inputs.version_type == 'canary' || inputs.version_type == 'stable' + # Required for this workflow to have permission to publish NPM packages + environment: npm-publish permissions: contents: read id-token: write @@ -130,18 +132,3 @@ jobs: env: NPM_TAG: ${{ steps.npm-tag.outputs.NPM_TAG }} run: ./scripts/publish-npm-packages.sh --dist-tag "$NPM_TAG" --registry 'https://registry.npmjs.org/' - - # TODO: finish this step - tag-nightly: - name: Tag nightly release - runs-on: github-hosted-ubuntu-x64-small - if: inputs.version_type == 'nightly' - - steps: - - name: Checkout workflow ref - uses: actions/checkout@v4 - with: - persist-credentials: false - - # TODO: tag the given release with nightly - diff --git a/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md b/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md index ea79a3686c6..dbe77c05013 100644 --- a/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md +++ b/docs/sources/dashboards/build-dashboards/modify-dashboard-settings/index.md @@ -10,7 +10,6 @@ labels: - cloud - enterprise - oss -menuTitle: Modify dashboard settings title: Modify dashboard settings description: Manage and edit your dashboard settings weight: 8 @@ -56,7 +55,7 @@ To access the dashboard setting page: Adjust dashboard time settings when you want to change the dashboard timezone, the local browser time, and specify auto-refresh time intervals. -1. On the **Settings** page, scroll down to the **Time Options** section of the **General** tab. +1. On the the **General** tab of the **Settings** page, scroll down to the **Time options** section. 1. Specify time settings as follows. - **Time zone:** Specify the local time zone of the service or system that you are monitoring. This can be helpful when monitoring a system or service that operates across several time zones. - **Default:** Grafana uses the default selected time zone for the user profile, team, or organization. If no time zone is specified for the user profile, a team the user is a member of, or the organization, then Grafana uses the local browser time. @@ -71,6 +70,21 @@ Adjust dashboard time settings when you want to change the dashboard timezone, t 1. Click **Save**. 1. Click **Exit edit**. +## Modify graph tooltip behavior + +Use this option to control tooltip and hover highlight behavior across graph panels (for example, time series). + +1. On the the **General** tab of the **Settings** page, scroll down to the **Panel options** section. +1. Choose from the following options to control the tooltip and hover highlight behavior across graph panels: + - **Default** - Tooltip and hover highlight behavior isn't shared across panels. + - **Shared crosshair** - When you hover the cursor over one graph panel in the dashboard, the crosshair is also displayed on all other graph panels in the dashboard. + - **Shared tooltip** - When you hover the cursor over one graph panel in the dashboard, the crosshair and tooltips are also displayed on all other graph panels in the dashboard. + +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. + ## Add tags You can add metadata to your dashboards using tags. Tags also give you the ability to filter the list of dashboards. @@ -79,7 +93,7 @@ Tags can be up to 50 characters long, including spaces. To add tags to a dashboard, follow these steps: -1. On the **Settings** page, scroll down to the **Tags** section of the **General** tab. +1. On the the **General** tab of the **Settings** page, scroll down to the **Tags** section. 1. In the field, enter a new or existing tag. If you're entering an existing tag, make sure that you spell it the same way or a new tag is created. diff --git a/docs/sources/upgrade-guide/when-to-upgrade/index.md b/docs/sources/upgrade-guide/when-to-upgrade/index.md index 1ffaf22fd31..e7a29e531e0 100644 --- a/docs/sources/upgrade-guide/when-to-upgrade/index.md +++ b/docs/sources/upgrade-guide/when-to-upgrade/index.md @@ -46,23 +46,19 @@ We provide release documentation in multiple places to address different needs: ## When to expect releases -Grafana currently follows a monthly release schedule. Below are the planned releases for 2025, though these dates may be subject to change: +Grafana currently follows a monthly release schedule. Below are the planned releases for the end of 2025 and the first part of 2026. However, these dates may be subject to change: | **Release date** | **Grafana versions** | **Release type** | | ---------------- | ------------------------- | ---------------- | -| Jan. 28, 2025 | 11.5 & Supported versions | Minor & patching | -| Feb. 18, 2025 | Supported versions | Patching | -| March 25, 2025 | 11.6 & Supported versions | Minor & patching | -| April 23, 2025 | Supported versions | Patching | -| May 5, 2025 | Grafana 12.0 | Major only | -| May 20, 2025 | Supported versions | Patching | -| June 17, 2025 | Supported versions | Patching | -| July 22, 2025 | 12.1 & Supported versions | Minor & patching | | Aug. 12, 2025 | Supported versions | Patching | | Sept. 23, 2025 | 12.2 & Supported versions | Minor & patching | | Oct. 21, 2025 | Supported versions | Patching | | Nov. 18, 2025 | 12.3 & Supported versions | Minor & patching | | Dec. 16, 2025 | Supported versions | Patching | +| Jan. 13, 2026 | Supported versions | Patching | +| Feb. 24, 2026 | 12.4 & Supported versions | Minor & patching | +| Mar. 24, 2026 | Supported versions | Patching | +| TBD | Grafana 13 | Major | ### A few important notes @@ -104,20 +100,16 @@ Here is an overview of version support through 2026: | **Version** | **Release date** | **Support end date** | **Support level** | | ------------------------- | ------------------ | -------------------- | ------------------ | -| 10.2.x | October 24, 2023 | July 24, 2024 | Not Supported | -| 10.3.x | January 23, 2024 | October 23, 2024 | Not Supported | -| 10.4.x (Last minor of 10) | March 5, 2024 | June 5, 2025 | Not Supported | -| 11.0.x | May 14, 2024 | February 14, 2025 | Not Supported | -| 11.1.x | June 25, 2024 | April 23, 2025 | Not Supported | -| 11.2.x | August 27, 2024 | May 27, 2025 | Not Supported | | 11.3.x | October 22, 2024 | July 22, 2025 | Not Supported | -| 11.4.x | December 5, 2024 | September 5, 2025 | Patch Support | -| 11.5.x | January 28, 2025 | October 28, 2025 | Patch Support | +| 11.4.x | December 5, 2024 | September 5, 2025 | Not Supported | +| 11.5.x | January 28, 2025 | October 28, 2025 | Not Supported | | 11.6.x (Last minor of 11) | March 25, 2025 | June 25, 2026 | Patch Support | | 12.0.x | May 5, 2025 | February 5, 2026 | Patch Support | | 12.1.x | July 22, 2025 | April 22, 2026 | Patch Support | -| 12.2.x | September 23, 2025 | June 23, 2026 | Yet to be released | +| 12.2.x | September 23, 2025 | June 23, 2026 | Patch Support | | 12.3.x | November 18, 2025 | August 18, 2026 | Yet to be released | +| 12.4.x (Last minor of 12) | February 24, 2026 | November 24, 2026 | Yet to be released | +| 13.0.0 | TBD | TBD | Yet to be released | ## How are these versions supported? diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 64453f86688..84f4b7b4ae3 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -17,6 +17,7 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" + pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" ) @@ -222,7 +223,7 @@ func (hs *HTTPServer) AdminDeleteUser(c *contextmodel.ReqContext) response.Respo return nil }) g.Go(func() error { - if err := hs.preferenceService.DeleteByUser(ctx, cmd.UserID); err != nil { + if err := hs.preferenceService.Delete(ctx, &pref.DeleteCommand{UserID: cmd.UserID}); err != nil { return err } return nil diff --git a/pkg/expr/convert_to_full_long.go b/pkg/expr/convert_to_full_long.go index 6c17badb1d8..dd626ca6421 100644 --- a/pkg/expr/convert_to_full_long.go +++ b/pkg/expr/convert_to_full_long.go @@ -14,8 +14,8 @@ const ( SQLDisplayFieldName = "__display_name__" // These are not types in the SDK or dataplane contract yet. - numericFullLongType = "numeric_full_long" - timeseriesFullLongType = "time_series_full_long" + numericFullLongType = "numeric-full-long" + timeseriesFullLongType = "timeseries-full-long" ) func ConvertToFullLong(frames data.Frames) (data.Frames, error) { diff --git a/pkg/promlib/models/query.go b/pkg/promlib/models/query.go index ae56d929ec2..ec32b7bc3cd 100644 --- a/pkg/promlib/models/query.go +++ b/pkg/promlib/models/query.go @@ -1,6 +1,7 @@ package models import ( + "context" "embed" "encoding/json" "fmt" @@ -15,6 +16,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + glog "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/promlib/intervalv2" ) @@ -190,7 +192,7 @@ type internalQueryModel struct { Interval string `json:"interval,omitempty"` } -func Parse(span trace.Span, query backend.DataQuery, dsScrapeInterval string, intervalCalculator intervalv2.Calculator, fromAlert bool, enableScope bool) (*Query, error) { +func Parse(ctx context.Context, log glog.Logger, span trace.Span, query backend.DataQuery, dsScrapeInterval string, intervalCalculator intervalv2.Calculator, fromAlert bool, enableScope bool) (*Query, error) { model := &internalQueryModel{} if err := json.Unmarshal(query.JSON, model); err != nil { return nil, err @@ -241,6 +243,7 @@ func Parse(span trace.Span, query backend.DataQuery, dsScrapeInterval string, in } if len(scopeFilters) > 0 || len(model.AdhocFilters) > 0 || len(model.GroupByKeys) > 0 { + log.Info("Applying scope filters", "scopeFiltersCount", len(scopeFilters), "adhocFiltersCount", len(model.AdhocFilters), "groupByKeysCount", len(model.GroupByKeys)) expr, err = ApplyFiltersAndGroupBy(expr, scopeFilters, model.AdhocFilters, model.GroupByKeys) if err != nil { return nil, err diff --git a/pkg/promlib/models/query_test.go b/pkg/promlib/models/query_test.go index 8826e0c37c5..cc2b2637442 100644 --- a/pkg/promlib/models/query_test.go +++ b/pkg/promlib/models/query_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/promlib/intervalv2" "github.com/grafana/grafana/pkg/promlib/models" ) @@ -44,7 +45,7 @@ func TestParse(t *testing.T) { RefID: "A", } - res, err := models.Parse(span, q, "15s", intervalCalculator, true, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, true, false) require.NoError(t, err) require.Equal(t, false, res.ExemplarQuery) }) @@ -61,7 +62,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Second*30, res.Step) }) @@ -79,7 +80,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Second*15, res.Step) }) @@ -97,7 +98,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Minute*20, res.Step) }) @@ -115,7 +116,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Minute*2, res.Step) }) @@ -133,7 +134,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "240s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "240s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, time.Minute*4, res.Step) }) @@ -152,7 +153,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [2m]})", res.Expr) require.Equal(t, 120*time.Second, res.Step) @@ -173,7 +174,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [2m]})", res.Expr) }) @@ -192,7 +193,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [120000]})", res.Expr) }) @@ -211,7 +212,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [120000]}) + rate(ALERTS{job=\"test\" [2m]})", res.Expr) }) @@ -230,7 +231,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [120000]}) + rate(ALERTS{job=\"test\" [2m]})", res.Expr) }) @@ -248,7 +249,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800s]})", res.Expr) }) @@ -266,7 +267,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800]})", res.Expr) }) @@ -284,7 +285,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800s]})", res.Expr) }) @@ -302,7 +303,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [0]})", res.Expr) }) @@ -320,7 +321,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [1]})", res.Expr) }) @@ -338,7 +339,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800000]})", res.Expr) }) @@ -356,7 +357,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [20]})", res.Expr) }) @@ -375,7 +376,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [20m0s]})", res.Expr) }) @@ -394,7 +395,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 1*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [1m0s]})", res.Expr) require.Equal(t, 1*time.Minute, res.Step) @@ -413,7 +414,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 2*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [135000]})", res.Expr) }) @@ -431,7 +432,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 2*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [135000]}) + rate(ALERTS{job=\"test\" [2m15s]})", res.Expr) }) @@ -450,7 +451,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 2*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "A", res.RefId) }) @@ -468,7 +469,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, 2*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [135000]}) + rate(ALERTS{job=\"test\" [2m15s]})", res.Expr) }) @@ -487,7 +488,7 @@ func TestParse(t *testing.T) { "range": true }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, true, res.RangeQuery) }) @@ -507,7 +508,7 @@ func TestParse(t *testing.T) { "instant": true }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, true, res.RangeQuery) require.Equal(t, true, res.InstantQuery) @@ -526,7 +527,7 @@ func TestParse(t *testing.T) { "refId": "A" }`, timeRange, time.Duration(1)*time.Minute) - res, err := models.Parse(span, q, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, true, res.RangeQuery) }) @@ -659,7 +660,7 @@ func TestRateInterval(t *testing.T) { t.Run(tt.name, func(t *testing.T) { q := mockQuery(tt.args.expr, tt.args.interval, tt.args.intervalMs, tt.args.timeRange) q.MaxDataPoints = 12384 - res, err := models.Parse(span, q, tt.args.dsScrapeInterval, intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, q, tt.args.dsScrapeInterval, intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, tt.want.Expr, res.Expr) require.Equal(t, tt.want.Step, res.Step) @@ -694,7 +695,7 @@ func TestRateInterval(t *testing.T) { "utcOffsetSec":3600 }`), } - res, err := models.Parse(span, query, "30s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, query, "30s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "sum(rate(process_cpu_seconds_total[2m0s]))", res.Expr) require.Equal(t, 30*time.Second, res.Step) @@ -729,7 +730,7 @@ func TestRateInterval(t *testing.T) { "maxDataPoints": 1055 }`), } - res, err := models.Parse(span, query, "15s", intervalCalculator, false, false) + res, err := models.Parse(context.Background(), log.New(), span, query, "15s", intervalCalculator, false, false) require.NoError(t, err) require.Equal(t, "sum(rate(cache_requests_total[1m0s]))", res.Expr) require.Equal(t, 15*time.Second, res.Step) diff --git a/pkg/promlib/querydata/request.go b/pkg/promlib/querydata/request.go index 5f3f9556648..ff3cefc4458 100644 --- a/pkg/promlib/querydata/request.go +++ b/pkg/promlib/querydata/request.go @@ -129,7 +129,7 @@ func (s *QueryData) handleQuery(ctx context.Context, bq backend.DataQuery, fromA hasPromQLScopeFeatureFlag bool) *backend.DataResponse { traceCtx, span := s.tracer.Start(ctx, "datasource.prometheus") defer span.End() - query, err := models.Parse(span, bq, s.TimeInterval, s.intervalCalculator, fromAlert, hasPromQLScopeFeatureFlag) + query, err := models.Parse(ctx, s.log, span, bq, s.TimeInterval, s.intervalCalculator, fromAlert, hasPromQLScopeFeatureFlag) if err != nil { return &backend.DataResponse{ Error: err, @@ -145,7 +145,7 @@ func (s *QueryData) handleQuery(ctx context.Context, bq backend.DataQuery, fromA func (s *QueryData) fetch(traceCtx context.Context, client *client.Client, q *models.Query) *backend.DataResponse { logger := s.log.FromContext(traceCtx) - logger.Debug("Sending query", "start", q.Start, "end", q.End, "step", q.Step, "query", q.Expr /*, "queryTimeout", s.QueryTimeout*/) + logger.Debug("Sending query", "start", q.Start, "end", q.End, "step", q.Step, "query", q.Expr) dr := &backend.DataResponse{ Frames: data.Frames{}, diff --git a/pkg/registry/apis/preferences/legacy/preferences.go b/pkg/registry/apis/preferences/legacy/preferences.go index e9fd6b32aed..ab7eb2d4891 100644 --- a/pkg/registry/apis/preferences/legacy/preferences.go +++ b/pkg/registry/apis/preferences/legacy/preferences.go @@ -13,11 +13,13 @@ import ( requestK8s "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" + authlib "github.com/grafana/authlib/types" preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" utilsOrig "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/preferences/utils" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + pref "github.com/grafana/grafana/pkg/services/preference" ) var ( @@ -26,13 +28,14 @@ var ( _ rest.Getter = (*preferenceStorage)(nil) _ rest.Lister = (*preferenceStorage)(nil) _ rest.Storage = (*preferenceStorage)(nil) - // _ rest.Creater = (*preferenceStorage)(nil) - // _ rest.Updater = (*preferenceStorage)(nil) - // _ rest.GracefulDeleter = (*preferenceStorage)(nil) + _ rest.Creater = (*preferenceStorage)(nil) + _ rest.Updater = (*preferenceStorage)(nil) + _ rest.GracefulDeleter = (*preferenceStorage)(nil) ) -func NewPreferencesStorage(namespacer request.NamespaceMapper, sql *LegacySQL) *preferenceStorage { +func NewPreferencesStorage(pref pref.Service, namespacer request.NamespaceMapper, sql *LegacySQL) *preferenceStorage { return &preferenceStorage{ + prefs: pref, namespacer: namespacer, sql: sql, tableConverter: preferences.PreferencesResourceInfo.TableConverter(), @@ -43,6 +46,7 @@ type preferenceStorage struct { namespacer request.NamespaceMapper tableConverter rest.TableConvertor sql *LegacySQL + prefs pref.Service } func (s *preferenceStorage) New() runtime.Object { @@ -73,7 +77,7 @@ func (s *preferenceStorage) List(ctx context.Context, options *internalversion.L return nil, err } ns := requestK8s.NamespaceValue(ctx) - if user.GetIsGrafanaAdmin() { + if user.GetIdentityType() == authlib.TypeAccessPolicy { user = nil // nill user can see everything } return s.sql.ListPreferences(ctx, ns, user, true) @@ -116,6 +120,151 @@ func (s *preferenceStorage) Get(ctx context.Context, name string, options *metav return nil, preferences.PreferencesResourceInfo.NewNotFound(name) } +func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runtime.Object, error) { + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + p, ok := obj.(*preferences.Preferences) + if !ok { + return nil, fmt.Errorf("expected preferences") + } + + owner, ok := utils.ParseOwnerFromName(p.Name) + if !ok { + return nil, fmt.Errorf("invalid name") + } + + cmd := &pref.SavePreferenceCommand{ + OrgID: user.GetOrgID(), + HomeDashboardUID: p.Spec.HomeDashboardUID, + } + if p.Spec.Timezone != nil { + cmd.Timezone = *p.Spec.Timezone + } + if p.Spec.WeekStart != nil { + cmd.WeekStart = *p.Spec.WeekStart + } + if p.Spec.Theme != nil { + cmd.Theme = *p.Spec.Theme + } + if p.Spec.Language != nil { + cmd.Language = *p.Spec.Language + } + if p.Spec.RegionalFormat != nil { + cmd.RegionalFormat = *p.Spec.RegionalFormat + } + if p.Spec.QueryHistory != nil { + cmd.QueryHistory = &pref.QueryHistoryPreference{ + HomeTab: *p.Spec.QueryHistory.HomeTab, + } + } + if p.Spec.Navbar != nil { + cmd.Navbar = &pref.NavbarPreference{ + BookmarkUrls: p.Spec.Navbar.BookmarkUrls, + } + } + if p.Spec.CookiePreferences != nil { + cmd.CookiePreferences = []pref.CookieType{} + if p.Spec.CookiePreferences.Analytics != nil { + cmd.CookiePreferences = append(cmd.CookiePreferences, "analytics") + } + if p.Spec.CookiePreferences.Functional != nil { + cmd.CookiePreferences = append(cmd.CookiePreferences, "functional") + } + if p.Spec.CookiePreferences.Performance != nil { + cmd.CookiePreferences = append(cmd.CookiePreferences, "performance") + } + } + + switch owner.Owner { + case utils.NamespaceResourceOwner: + // the org ID is already set + + case utils.UserResourceOwner: + if user.GetIdentifier() != owner.Identifier { + return nil, fmt.Errorf("only the user can save preferences") + } + cmd.UserID, err = user.GetInternalID() + if err != nil { + return nil, err + } + case utils.TeamResourceOwner: + cmd.TeamID, err = s.sql.getLegacyTeamID(ctx, user.GetOrgID(), owner.Identifier) + if err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unsupported name") + } + + if err = s.prefs.Save(ctx, cmd); err != nil { + return nil, err + } + return s.Get(ctx, owner.AsName(), &metav1.GetOptions{}) +} + +// Create implements rest.Creater. +func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + return s.save(ctx, obj) +} + +// Update implements rest.Updater. +func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + old, err := s.Get(ctx, name, &metav1.GetOptions{}) + if err != nil { + return nil, false, err + } + + obj, err := objInfo.UpdatedObject(ctx, old) + if err != nil { + return nil, false, err + } + + obj, err = s.save(ctx, obj) + return obj, false, err +} + +// Delete implements rest.GracefulDeleter. +func (s *preferenceStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, false, err + } + + owner, ok := utils.ParseOwnerFromName(name) + if !ok { + return nil, false, fmt.Errorf("invalid name") + } + + cmd := &pref.DeleteCommand{} + + switch owner.Owner { + case utils.TeamResourceOwner: + cmd.TeamID, err = user.GetInternalID() + if err != nil { + return nil, false, err + } + + case utils.UserResourceOwner: + cmd.UserID, err = user.GetInternalID() + if err != nil { + return nil, false, err + } + + case utils.NamespaceResourceOwner: + cmd.OrgID = user.GetOrgID() + + default: + return nil, false, fmt.Errorf("unsupported owner") + } + + err = s.prefs.Delete(ctx, cmd) + return nil, (err == nil), err +} + func asPreferencesResource(ns string, p *preferenceModel) preferences.Preferences { owner := utils.OwnerReference{} if p.TeamUID.Valid { diff --git a/pkg/registry/apis/preferences/legacy/sql.go b/pkg/registry/apis/preferences/legacy/sql.go index bf3e8900f81..fb4cc60fcbf 100644 --- a/pkg/registry/apis/preferences/legacy/sql.go +++ b/pkg/registry/apis/preferences/legacy/sql.go @@ -49,7 +49,7 @@ func NewLegacySQL(db legacysql.LegacyDatabaseProvider) *LegacySQL { } // NOTE: this does not support paging -- lets check if that will be a problem in cloud -func (s *LegacySQL) GetStars(ctx context.Context, orgId int64, user string) ([]dashboardStars, int64, error) { +func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user string) ([]dashboardStars, int64, error) { var max sql.NullString sql, err := s.db(ctx) if err != nil { @@ -120,7 +120,10 @@ func (s *LegacySQL) GetStars(ctx context.Context, orgId int64, user string) ([]d return nil, 0, fmt.Errorf("unable to get RV %w", err) } if max.Valid && max.String != "" { - fmt.Printf("max RV: %s\n", max.String) + t, _ := time.Parse(time.RFC3339, max.String) + if !t.IsZero() { + updated = t + } } else { updated = s.startup } @@ -206,7 +209,10 @@ func (s *LegacySQL) listPreferences(ctx context.Context, return nil, 0, fmt.Errorf("unable to get RV %w", err) } if max.Valid && max.String != "" { - fmt.Printf("max RV: %s\n", max.String) + t, _ := time.Parse(time.RFC3339, max.String) + if !t.IsZero() { + rv.Time = t + } } else { rv.Time = s.startup } @@ -229,7 +235,7 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit found, rv, err := s.listPreferences(ctx, ns, info.OrgID, func(req *preferencesQuery) (bool, error) { if user != nil { - req.UserUID = user.GetRawIdentifier() + req.UserUID = user.GetIdentifier() teams, err = s.GetTeams(ctx, &identity.StaticRequester{ OrgID: info.OrgID, UserUID: req.UserUID, @@ -243,7 +249,7 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit return true } if p.UserUID.String != "" { - return user.GetRawIdentifier() == p.UserUID.String + return user.GetIdentifier() == p.UserUID.String } if p.TeamUID.String != "" { return slices.Contains(teams, p.TeamUID.String) @@ -293,3 +299,15 @@ func (s *LegacySQL) GetTeams(ctx context.Context, id authlib.AuthInfo, admin boo err = sess.Select(ctx, &teams, q, req.GetArgs()...) return teams, err } + +func (s *LegacySQL) getLegacyTeamID(ctx context.Context, orgId int64, team string) (int64, error) { + sql, err := s.db(ctx) + if err != nil { + return 0, err + } + + var id int64 + sess := sql.DB.GetSqlxSession() + err = sess.Select(ctx, &id, "SELECT id FROM team WHERE org_id=? AND uid=?", orgId, team) + return id, err +} diff --git a/pkg/registry/apis/preferences/legacy/stars.go b/pkg/registry/apis/preferences/legacy/stars.go index 87f5300799d..b048c249170 100644 --- a/pkg/registry/apis/preferences/legacy/stars.go +++ b/pkg/registry/apis/preferences/legacy/stars.go @@ -97,13 +97,13 @@ func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversi return nil, err } - user := userInfo.GetUID() - if userInfo.GetIsGrafanaAdmin() || userInfo.GetIdentityType() == authlib.TypeAccessPolicy { + user := userInfo.GetIdentifier() + if userInfo.GetIdentityType() == authlib.TypeAccessPolicy { user = "" // can see everything } list := &preferences.StarsList{} - found, rv, err := s.sql.GetStars(ctx, ns.OrgID, user) + found, rv, err := s.sql.getDashboardStars(ctx, ns.OrgID, user) if err != nil { return nil, err } @@ -137,7 +137,7 @@ func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *m return nil, err } - found, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Identifier) + found, _, err := s.sql.getDashboardStars(ctx, ns.OrgID, owner.Identifier) if err != nil { return nil, err } @@ -187,7 +187,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star }}, err } - current, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Identifier) + current, _, err := s.sql.getDashboardStars(ctx, ns.OrgID, owner.Identifier) if err != nil { return nil, err } diff --git a/pkg/registry/apis/preferences/merged_preferences.go b/pkg/registry/apis/preferences/preferences_merged.go similarity index 100% rename from pkg/registry/apis/preferences/merged_preferences.go rename to pkg/registry/apis/preferences/preferences_merged.go diff --git a/pkg/registry/apis/preferences/merged_preferences_test.go b/pkg/registry/apis/preferences/preferences_merged_test.go similarity index 100% rename from pkg/registry/apis/preferences/merged_preferences_test.go rename to pkg/registry/apis/preferences/preferences_merged_test.go diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index 6fff343f233..ff186c6bdb2 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -72,7 +72,7 @@ func RegisterAPIService( namespacer := request.GetNamespaceMapper(cfg) if prefs != nil { - builder.legacyPrefs = legacy.NewPreferencesStorage(namespacer, sql) + builder.legacyPrefs = legacy.NewPreferencesStorage(prefs, namespacer, sql) } if stars != nil { builder.legacyStars = legacy.NewDashboardStarsStorage(stars, users, namespacer, sql) diff --git a/pkg/registry/apis/preferences/update_stars.go b/pkg/registry/apis/preferences/stars_update.go similarity index 100% rename from pkg/registry/apis/preferences/update_stars.go rename to pkg/registry/apis/preferences/stars_update.go diff --git a/pkg/registry/apis/preferences/update_stars_test.go b/pkg/registry/apis/preferences/stars_update_test.go similarity index 100% rename from pkg/registry/apis/preferences/update_stars_test.go rename to pkg/registry/apis/preferences/stars_update_test.go diff --git a/pkg/registry/apis/provisioning/jobs.go b/pkg/registry/apis/provisioning/jobs.go index 4a344b60192..b1e174a04b7 100644 --- a/pkg/registry/apis/provisioning/jobs.go +++ b/pkg/registry/apis/provisioning/jobs.go @@ -20,16 +20,23 @@ type JobQueueGetter interface { } type jobsConnector struct { - repoGetter RepoGetter - jobs JobQueueGetter - historic jobs.HistoryReader + repoGetter RepoGetter + statusPatcherProvider StatusPatcherProvider + jobs JobQueueGetter + historic jobs.HistoryReader } -func NewJobsConnector(repoGetter RepoGetter, jobs JobQueueGetter, historic jobs.HistoryReader) *jobsConnector { +func NewJobsConnector( + repoGetter RepoGetter, + statusPatcherProvider StatusPatcherProvider, + jobs JobQueueGetter, + historic jobs.HistoryReader, +) *jobsConnector { return &jobsConnector{ - repoGetter: repoGetter, - jobs: jobs, - historic: historic, + repoGetter: repoGetter, + statusPatcherProvider: statusPatcherProvider, + jobs: jobs, + historic: historic, } } @@ -125,6 +132,23 @@ func (c *jobsConnector) Connect( } spec.Repository = name + // If a sync job is being created, we should update its status to pending. + if spec.Pull != nil { + err = c.statusPatcherProvider.GetStatusPatcher().Patch(ctx, cfg, map[string]interface{}{ + "op": "replace", + "path": "/status/sync", + "value": &provisioning.SyncStatus{ + State: provisioning.JobStatePending, + LastRef: cfg.Status.Sync.LastRef, + Started: time.Now().UnixMilli(), + }, + }) + if err != nil { + responder.Error(err) + return + } + } + job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec) if err != nil { responder.Error(err) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 95aea89904c..5590cbeafe5 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -494,7 +494,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.RepositoryResourceInfo.StoragePath("history")] = &historySubresource{ repoGetter: b, } - storage[provisioning.RepositoryResourceInfo.StoragePath("jobs")] = NewJobsConnector(b, b, jobHistory) + storage[provisioning.RepositoryResourceInfo.StoragePath("jobs")] = NewJobsConnector(b, b, b, jobHistory) // Add any extra storage for _, extra := range b.extras { diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 51bbdf261b1..79b633515c1 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -48,7 +48,6 @@ const ( NavIDAlerting = "alerting" NavIDObservability = "observability" NavIDInfrastructure = "infrastructure" - NavIDFrontend = "frontend" NavIDReporting = "reports" NavIDApps = "apps" NavIDCfgGeneral = "cfg/general" diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 5ae119d97ff..27a31a5bcff 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -260,10 +260,21 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n } } + sectionChildren := []*navtree.NavLink{appLink} + // asserts pages expand to root Observability section instead of it's own node + if plugin.ID == "grafana-asserts-app" { + sectionChildren = appLink.Children + + // keep current sorting if the pages, but above all the other apps + for _, child := range sectionChildren { + child.SortWeight = -100 + child.SortWeight + } + } + if sectionID == navtree.NavIDRoot { treeRoot.AddSection(appLink) } else if navNode := treeRoot.FindById(sectionID); navNode != nil { - navNode.Children = append(navNode.Children, appLink) + navNode.Children = append(navNode.Children, sectionChildren...) } else { switch sectionID { case navtree.NavIDApps: @@ -272,18 +283,19 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n Icon: "layer-group", SubTitle: "App plugins that extend the Grafana experience", Id: navtree.NavIDApps, - Children: []*navtree.NavLink{appLink}, + Children: sectionChildren, SortWeight: navtree.WeightApps, Url: s.cfg.AppSubURL + "/apps", }) case navtree.NavIDObservability: + treeRoot.AddSection(&navtree.NavLink{ Text: "Observability", Id: navtree.NavIDObservability, SubTitle: "Monitor infrastructure and applications in real time with Grafana Cloud's fully managed observability suite", Icon: "heart-rate", SortWeight: navtree.WeightObservability, - Children: []*navtree.NavLink{appLink}, + Children: sectionChildren, Url: s.cfg.AppSubURL + "/observability", }) case navtree.NavIDInfrastructure: @@ -293,19 +305,9 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n SubTitle: "Understand your infrastructure's health", Icon: "heart-rate", SortWeight: navtree.WeightInfrastructure, - Children: []*navtree.NavLink{appLink}, + Children: sectionChildren, Url: s.cfg.AppSubURL + "/infrastructure", }) - case navtree.NavIDFrontend: - treeRoot.AddSection(&navtree.NavLink{ - Text: "Frontend", - Id: navtree.NavIDFrontend, - SubTitle: "Gain real user monitoring insights", - Icon: "frontend-observability", - SortWeight: navtree.WeightFrontend, - Children: []*navtree.NavLink{appLink}, - Url: s.cfg.AppSubURL + "/frontend", - }) case navtree.NavIDAlertsAndIncidents: alertsAndIncidentsChildren := []*navtree.NavLink{} for _, alertingNode := range alertingNodes { @@ -332,7 +334,7 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n SubTitle: "Optimize performance with k6 and Synthetic Monitoring insights", Icon: "k6", SortWeight: navtree.WeightTestingAndSynthetics, - Children: []*navtree.NavLink{appLink}, + Children: sectionChildren, Url: s.cfg.AppSubURL + "/testing-and-synthetics", }) case navtree.NavIDAdaptiveTelemetry: @@ -372,11 +374,11 @@ func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID st func (s *ServiceImpl) readNavigationSettings() { s.navigationAppConfig = map[string]NavigationAppConfig{ "grafana-asserts-app": {SectionID: navtree.NavIDObservability, SortWeight: 1, Icon: "asserts"}, - "grafana-app-observability-app": {SectionID: navtree.NavIDObservability, SortWeight: 2, Text: "Application"}, - "grafana-csp-app": {SectionID: navtree.NavIDObservability, SortWeight: 3, Icon: "cloud-provider"}, - "grafana-k8s-app": {SectionID: navtree.NavIDObservability, SortWeight: 4, Text: "Kubernetes"}, - "grafana-dbo11y-app": {SectionID: navtree.NavIDObservability, SortWeight: 5, Text: "Database"}, - "grafana-kowalski-app": {SectionID: navtree.NavIDObservability, SortWeight: 6, Text: "Frontend"}, + "grafana-kowalski-app": {SectionID: navtree.NavIDObservability, SortWeight: 2, Text: "Frontend"}, + "grafana-app-observability-app": {SectionID: navtree.NavIDObservability, SortWeight: 3, Text: "Application"}, + "grafana-dbo11y-app": {SectionID: navtree.NavIDObservability, SortWeight: 4, Text: "Database"}, + "grafana-k8s-app": {SectionID: navtree.NavIDObservability, SortWeight: 5, Text: "Kubernetes"}, + "grafana-csp-app": {SectionID: navtree.NavIDObservability, SortWeight: 6, Icon: "cloud-provider"}, "grafana-metricsdrilldown-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 1, Text: "Metrics"}, "grafana-lokiexplore-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 2, Text: "Logs"}, "grafana-exploretraces-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 3, Text: "Traces"}, @@ -390,10 +392,10 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-slo-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 7}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfgPlugins, SortWeight: 3}, "grafana-costmanagementui-app": {SectionID: navtree.NavIDCfg, Text: "Cost management"}, - "grafana-adaptive-metrics-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 1, Text: "Adaptive Metrics", SubTitle: "Analyzes and reduces unused metrics and cardinality to help you focus on your most valuable performance data."}, - "grafana-adaptivelogs-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 2, Text: "Adaptive Logs", SubTitle: "Analyzes log patterns to drop repetitive lines and accelerate troubleshooting."}, - "grafana-adaptivetraces-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 3, Text: "Adaptive Traces", SubTitle: "Analyzes and retains the most valuable traces, providing the performance insights needed to resolve issues faster."}, - "grafana-adaptiveprofiles-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 4, Text: "Adaptive Profiles", SubTitle: "Analyzes application profiles to pinpoint the root cause of performance issues and accelerate resolution."}, + "grafana-adaptive-metrics-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 1}, + "grafana-adaptivelogs-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 2}, + "grafana-adaptivetraces-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 3}, + "grafana-adaptiveprofiles-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 4}, "grafana-attributions-app": {SectionID: navtree.NavIDCfg, Text: "Attributions"}, "grafana-logvolumeexplorer-app": {SectionID: navtree.NavIDCfg, Text: "Log Volume Explorer"}, "grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApps + 1, Text: "Connections", Icon: "adjust-circle"}, diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index 87b0276f370..53c06601900 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -387,7 +387,7 @@ func TestReadingNavigationSettings(t *testing.T) { require.Equal(t, "dashboards", service.navigationAppConfig["grafana-k8s-app"].SectionID) require.Equal(t, "admin", service.navigationAppConfig["other-app"].SectionID) - require.Equal(t, int64(4), service.navigationAppConfig["grafana-k8s-app"].SortWeight) + require.Equal(t, int64(5), service.navigationAppConfig["grafana-k8s-app"].SortWeight) require.Equal(t, int64(12), service.navigationAppConfig["other-app"].SortWeight) require.Equal(t, "admin", service.navigationAppPathConfig["/a/grafana-k8s-app/foo"].SectionID) diff --git a/pkg/services/preference/model.go b/pkg/services/preference/model.go index edcde2f9fb2..26fdc11ac02 100644 --- a/pkg/services/preference/model.go +++ b/pkg/services/preference/model.go @@ -74,6 +74,12 @@ type SavePreferenceCommand struct { Navbar *NavbarPreference `json:"navbar,omitempty"` } +// One (and only one) of the values must be non-zero +type DeleteCommand struct { + OrgID int64 + UserID int64 + TeamID int64 +} type PatchPreferenceCommand struct { UserID int64 OrgID int64 diff --git a/pkg/services/preference/pref.go b/pkg/services/preference/pref.go index 1da5345d16a..6c6697d442a 100644 --- a/pkg/services/preference/pref.go +++ b/pkg/services/preference/pref.go @@ -10,5 +10,5 @@ type Service interface { Save(context.Context, *SavePreferenceCommand) error Patch(context.Context, *PatchPreferenceCommand) error GetDefaults() *Preference - DeleteByUser(context.Context, int64) error + Delete(context.Context, *DeleteCommand) error } diff --git a/pkg/services/preference/prefimpl/inmemory_test.go b/pkg/services/preference/prefimpl/inmemory_test.go index 63625cdb397..d24d230e0b7 100644 --- a/pkg/services/preference/prefimpl/inmemory_test.go +++ b/pkg/services/preference/prefimpl/inmemory_test.go @@ -121,6 +121,6 @@ func (s *inmemStore) Update(ctx context.Context, preference *pref.Preference) er return nil } -func (s *inmemStore) DeleteByUser(ctx context.Context, userID int64) error { +func (s *inmemStore) Delete(context.Context, *pref.DeleteCommand) error { panic("not yet implemented") } diff --git a/pkg/services/preference/prefimpl/pref.go b/pkg/services/preference/prefimpl/pref.go index 392f0617048..429feedfa79 100644 --- a/pkg/services/preference/prefimpl/pref.go +++ b/pkg/services/preference/prefimpl/pref.go @@ -272,8 +272,8 @@ func (s *Service) GetDefaults() *pref.Preference { } } -func (s *Service) DeleteByUser(ctx context.Context, userID int64) error { - return s.store.DeleteByUser(ctx, userID) +func (s *Service) Delete(ctx context.Context, cmd *pref.DeleteCommand) error { + return s.store.Delete(ctx, cmd) } func parseCookiePreferences(prefs []pref.CookieType) (map[string]struct{}, error) { diff --git a/pkg/services/preference/prefimpl/store.go b/pkg/services/preference/prefimpl/store.go index 7c8575a8a07..192f92cfb04 100644 --- a/pkg/services/preference/prefimpl/store.go +++ b/pkg/services/preference/prefimpl/store.go @@ -12,5 +12,5 @@ type store interface { // Insert adds a new preference and returns its sequential ID Insert(context.Context, *pref.Preference) (int64, error) Update(context.Context, *pref.Preference) error - DeleteByUser(context.Context, int64) error + Delete(context.Context, *pref.DeleteCommand) error } diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go index 40015e80425..4bb971029cc 100644 --- a/pkg/services/preference/prefimpl/store_test.go +++ b/pkg/services/preference/prefimpl/store_test.go @@ -185,9 +185,10 @@ func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { require.NoError(t, err) }) t.Run("delete preference by user", func(t *testing.T) { - err := prefStore.DeleteByUser(context.Background(), user.SignedInUser{}.UserID) + userId := int64(1) + err := prefStore.Delete(context.Background(), &pref.DeleteCommand{UserID: userId}) require.NoError(t, err) - query := &pref.Preference{OrgID: 0, UserID: user.SignedInUser{}.UserID, TeamID: 0} + query := &pref.Preference{OrgID: 0, UserID: userId, TeamID: 0} _, err = prefStore.Get(context.Background(), query) require.EqualError(t, err, pref.ErrPrefNotFound.Error()) }) diff --git a/pkg/services/preference/prefimpl/xorm_store.go b/pkg/services/preference/prefimpl/xorm_store.go index 286abd57885..1b5b2535829 100644 --- a/pkg/services/preference/prefimpl/xorm_store.go +++ b/pkg/services/preference/prefimpl/xorm_store.go @@ -2,6 +2,7 @@ package prefimpl import ( "context" + "fmt" "strings" "github.com/grafana/grafana/pkg/infra/db" @@ -80,10 +81,27 @@ func (s *sqlStore) Insert(ctx context.Context, cmd *pref.Preference) (int64, err return ID, err } -func (s *sqlStore) DeleteByUser(ctx context.Context, userID int64) error { - return s.db.WithDbSession(ctx, func(dbSession *db.Session) error { - var rawSQL = "DELETE FROM preferences WHERE user_id = ?" - _, err := dbSession.Exec(rawSQL, userID) - return err - }) +func (s *sqlStore) Delete(ctx context.Context, cmd *pref.DeleteCommand) error { + if cmd.UserID > 0 { + return s.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var rawSQL = "DELETE FROM preferences WHERE user_id = ?" + _, err := dbSession.Exec(rawSQL, cmd.UserID) + return err + }) + } + if cmd.TeamID > 0 { + return s.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var rawSQL = "DELETE FROM preferences WHERE team_id = ?" + _, err := dbSession.Exec(rawSQL, cmd.TeamID) + return err + }) + } + if cmd.OrgID > 0 { + return s.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var rawSQL = "DELETE FROM preferences WHERE org_id = ? AND user_id=0 AND team_id=0" + _, err := dbSession.Exec(rawSQL, cmd.OrgID) + return err + }) + } + return fmt.Errorf("expecting one of team, org, user to be non-zero") } diff --git a/pkg/services/preference/preftest/fake.go b/pkg/services/preference/preftest/fake.go index 8c6d7e07708..c8cf6b0be19 100644 --- a/pkg/services/preference/preftest/fake.go +++ b/pkg/services/preference/preftest/fake.go @@ -35,6 +35,6 @@ func (f *FakePreferenceService) Patch(ctx context.Context, cmd *pref.PatchPrefer return f.ExpectedError } -func (f *FakePreferenceService) DeleteByUser(context.Context, int64) error { +func (f *FakePreferenceService) Delete(context.Context, *pref.DeleteCommand) error { return f.ExpectedError } diff --git a/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json b/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json index c5d87c4088d..485dafd5eea 100644 --- a/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json @@ -43,6 +43,98 @@ ], "description": "list objects of kind Preferences", "operationId": "listPreferences", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], "responses": { "200": { "description": "OK", @@ -82,52 +174,131 @@ "kind": "Preferences" } }, + "post": { + "tags": [ + "Preferences" + ], + "description": "create Preferences", + "operationId": "createPreferences", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "preferences.grafana.app", + "version": "v1alpha1", + "kind": "Preferences" + } + }, "parameters": [ - { - "name": "allowWatchBookmarks", - "in": "query", - "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, { "name": "namespace", "in": "path", @@ -146,51 +317,6 @@ "type": "string", "uniqueItems": true } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "watch", - "in": "query", - "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - "schema": { - "type": "boolean", - "uniqueItems": true - } } ] }, @@ -260,6 +386,330 @@ "kind": "Preferences" } }, + "put": { + "tags": [ + "Preferences" + ], + "description": "replace the specified Preferences", + "operationId": "replacePreferences", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "preferences.grafana.app", + "version": "v1alpha1", + "kind": "Preferences" + } + }, + "delete": { + "tags": [ + "Preferences" + ], + "description": "delete Preferences", + "operationId": "deletePreferences", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "preferences.grafana.app", + "version": "v1alpha1", + "kind": "Preferences" + } + }, + "patch": { + "tags": [ + "Preferences" + ], + "description": "partially update the specified Preferences", + "operationId": "updatePreferences", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Preferences" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "preferences.grafana.app", + "version": "v1alpha1", + "kind": "Preferences" + } + }, "parameters": [ { "name": "name", diff --git a/pkg/tests/apis/preferences/preferences_test.go b/pkg/tests/apis/preferences/preferences_test.go index 01caaae8318..5de09e16fa8 100644 --- a/pkg/tests/apis/preferences/preferences_test.go +++ b/pkg/tests/apis/preferences/preferences_test.go @@ -85,6 +85,8 @@ func TestIntegrationPreferences(t *testing.T) { }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user") + adminPrefsName := "user-" + clientAdmin.Args.User.Identity.GetIdentifier() + // Admin has access to all three (namespace, team, and user) rsp, err = clientAdmin.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) @@ -95,9 +97,31 @@ func TestIntegrationPreferences(t *testing.T) { require.Equal(t, []string{ "namespace", fmt.Sprintf("team-%s", helper.Org1.Staff.UID), - fmt.Sprintf("user-%s", clientAdmin.Args.User.Identity.GetIdentifier()), + adminPrefsName, }, names) + obj, err := clientAdmin.Resource.Get(ctx, adminPrefsName, metav1.GetOptions{}) + require.NoError(t, err) + jj, err := json.MarshalIndent(obj.Object["spec"], "", " ") + require.NoError(t, err) + require.JSONEq(t, `{ + "weekStart":"saturday" + }`, string(jj)) + obj.Object["spec"] = map[string]any{ + "weekStart": "saturday", + "regionalFormat": "dd/mm/yyyy", + } + + // Set the regional format via k8s API + obj, err = clientAdmin.Resource.Update(ctx, obj, metav1.UpdateOptions{}) + require.NoError(t, err) + jj, err = json.MarshalIndent(obj.Object["spec"], "", " ") + require.NoError(t, err) + require.JSONEq(t, `{ + "weekStart": "saturday", + "regionalFormat": "dd/mm/yyyy" + }`, string(jj)) + // The viewer should only have namespace (eg org level) permissions rsp, err = clientViewer.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) @@ -118,14 +142,14 @@ func TestIntegrationPreferences(t *testing.T) { }, &shim{}) require.Equal(t, http.StatusOK, bootdata.Response.StatusCode, "get bootdata preferences") - jj, _ := json.Marshal(bootdata.Result.User) + jj, _ = json.Marshal(bootdata.Result.User) require.JSONEq(t, `{ "timezone":"africa", "weekStart":"saturday", "theme":"dark", "language":"en-US", `+ // FROM global default! - `"regionalFormat":"" - }`, string(jj)) + `"regionalFormat": ""}`, // why empty? + string(jj)) merged := apis.DoRequest(helper, apis.RequestParams{ User: clientAdmin.Args.User, @@ -133,9 +157,10 @@ func TestIntegrationPreferences(t *testing.T) { Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged", }, &preferences.Preferences{}) require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences") - require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user - require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team - require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org - require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini + require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user + require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team + require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org + require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini + require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update }) } diff --git a/public/app/api/clients/preferences/v1alpha1/endpoints.gen.ts b/public/app/api/clients/preferences/v1alpha1/endpoints.gen.ts index ac0a34e9af5..2596f67306f 100644 --- a/public/app/api/clients/preferences/v1alpha1/endpoints.gen.ts +++ b/public/app/api/clients/preferences/v1alpha1/endpoints.gen.ts @@ -14,12 +14,12 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/preferences`, params: { + pretty: queryArg.pretty, allowWatchBookmarks: queryArg.allowWatchBookmarks, continue: queryArg['continue'], fieldSelector: queryArg.fieldSelector, labelSelector: queryArg.labelSelector, limit: queryArg.limit, - pretty: queryArg.pretty, resourceVersion: queryArg.resourceVersion, resourceVersionMatch: queryArg.resourceVersionMatch, sendInitialEvents: queryArg.sendInitialEvents, @@ -29,6 +29,20 @@ const injectedRtkApi = api }), providesTags: ['Preferences'], }), + createPreferences: build.mutation({ + query: (queryArg) => ({ + url: `/preferences`, + method: 'POST', + body: queryArg.preferences, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Preferences'], + }), mergedPreferences: build.query({ query: () => ({ url: `/preferences/merged` }), providesTags: ['Preferences'], @@ -42,6 +56,50 @@ const injectedRtkApi = api }), providesTags: ['Preferences'], }), + replacePreferences: build.mutation({ + query: (queryArg) => ({ + url: `/preferences/${queryArg.name}`, + method: 'PUT', + body: queryArg.preferences, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Preferences'], + }), + deletePreferences: build.mutation({ + query: (queryArg) => ({ + url: `/preferences/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['Preferences'], + }), + updatePreferences: build.mutation({ + query: (queryArg) => ({ + url: `/preferences/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['Preferences'], + }), listStars: build.query({ query: (queryArg) => ({ url: `/stars`, @@ -173,6 +231,8 @@ export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; export type GetApiResourcesApiArg = void; export type ListPreferencesApiResponse = /** status 200 OK */ PreferencesList; export type ListPreferencesApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ allowWatchBookmarks?: boolean; /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". @@ -187,8 +247,6 @@ export type ListPreferencesApiArg = { The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ limit?: number; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. Defaults to unset */ @@ -216,6 +274,21 @@ export type ListPreferencesApiArg = { /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ watch?: boolean; }; +export type CreatePreferencesApiResponse = /** status 200 OK */ + | Preferences + | /** status 201 Created */ Preferences + | /** status 202 Accepted */ Preferences; +export type CreatePreferencesApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + preferences: Preferences; +}; export type MergedPreferencesApiResponse = /** status 200 undefined */ any; export type MergedPreferencesApiArg = void; export type GetPreferencesApiResponse = /** status 200 OK */ Preferences; @@ -225,6 +298,53 @@ export type GetPreferencesApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ pretty?: string; }; +export type ReplacePreferencesApiResponse = /** status 200 OK */ Preferences | /** status 201 Created */ Preferences; +export type ReplacePreferencesApiArg = { + /** name of the Preferences */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + preferences: Preferences; +}; +export type DeletePreferencesApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeletePreferencesApiArg = { + /** name of the Preferences */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdatePreferencesApiResponse = /** status 200 OK */ Preferences | /** status 201 Created */ Preferences; +export type UpdatePreferencesApiArg = { + /** name of the Preferences */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; export type ListStarsApiResponse = /** status 200 OK */ StarsList; export type ListStarsApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -608,6 +728,51 @@ export type PreferencesList = { kind?: string; metadata: ListMeta; }; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type Patch = object; export type StarsResource = { group: string; kind: string; @@ -657,56 +822,15 @@ export type StarsList = { kind?: string; metadata: ListMeta; }; -export type StatusCause = { - /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. - - Examples: - "name" - the field "name" on the current resource - "items[0].name" - the field "name" on the first array entry in "items" */ - field?: string; - /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ - message?: string; - /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ - reason?: string; -}; -export type StatusDetails = { - /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ - causes?: StatusCause[]; - /** The group attribute of the resource associated with the status StatusReason. */ - group?: string; - /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ - name?: string; - /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ - retryAfterSeconds?: number; - /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ - uid?: string; -}; -export type Status = { - /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ - apiVersion?: string; - /** Suggested HTTP return code for this status, 0 if not set. */ - code?: number; - /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ - details?: StatusDetails; - /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - /** A human-readable description of the status of this operation. */ - message?: string; - /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - metadata?: ListMeta; - /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ - reason?: string; - /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ - status?: string; -}; -export type Patch = object; export const { useGetApiResourcesQuery, useListPreferencesQuery, + useCreatePreferencesMutation, useMergedPreferencesQuery, useGetPreferencesQuery, + useReplacePreferencesMutation, + useDeletePreferencesMutation, + useUpdatePreferencesMutation, useListStarsQuery, useCreateStarsMutation, useDeletecollectionStarsMutation, diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index be793d5f1d1..f918af82afa 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -589,13 +589,14 @@ export class DashboardScene extends SceneObjectBase impleme this.setState({ overlay: undefined }); } - public async onStarDashboard() { + public async onStarDashboard(isStarred?: boolean) { const { meta, uid } = this.state; + isStarred = isStarred ?? Boolean(meta.isStarred); if (!uid) { return; } try { - const result = await getDashboardSrv().starDashboard(uid, Boolean(meta.isStarred)); + const result = await getDashboardSrv().starDashboard(uid, isStarred); this.setState({ meta: { diff --git a/public/app/features/stars/StarToolbarButton.tsx b/public/app/features/stars/StarToolbarButton.tsx index b8c3a612f6c..3a9949ed42a 100644 --- a/public/app/features/stars/StarToolbarButton.tsx +++ b/public/app/features/stars/StarToolbarButton.tsx @@ -1,8 +1,9 @@ import { useMemo } from 'react'; +import { useAsyncRetry } from 'react-use'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; +import { config, getBackendSrv } from '@grafana/runtime'; import { Icon, ToolbarButton } from '@grafana/ui'; import { useAddStarMutation, useRemoveStarMutation, useListStarsQuery } from 'app/api/clients/preferences/v1alpha1'; import { contextSrv } from 'app/core/core'; @@ -63,18 +64,29 @@ export function StarToolbarButtonApiServer({ group, kind, id }: Pick { + return getBackendSrv().get('api/user/stars'); + }); + + if (!starredUids || !uid) { + return null; + } + + const isStarred = starredUids?.includes(uid); return ( } data-testid={selectors.components.NavToolbar.markAsFavorite} - onClick={() => { + onClick={async () => { DashboardInteractions.toolbarFavoritesClick(); - dashboard.onStarDashboard(); + await dashboard.onStarDashboard(isStarred); + retry(); }} /> );