From 08b18113d2a9b786e823c850faa5d23da04175a9 Mon Sep 17 00:00:00 2001 From: Steve Simpson Date: Mon, 27 May 2024 16:18:46 +0200 Subject: [PATCH 01/12] Alerting: Wire up alertmanagerRemoteOnly feature toggle. (#88329) * Alerting: Wire up alertmanagerRemoteOnly feature toggle. Though the mode isn't feature complete yet, it will be useful to have the feature toggle wired up in order to start testing. * Apply suggestions from code review Co-authored-by: Santiago * Formatting --------- Co-authored-by: Santiago --- pkg/services/ngalert/ngalert.go | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 11d054497f0..4ae4fe4d9e3 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -164,7 +164,7 @@ func (ng *AlertNG) init() error { // If enabled, configure the remote Alertmanager. // - If several toggles are enabled, the order of precedence is RemoteOnly, RemotePrimary, RemoteSecondary // - If no toggles are enabled, we default to using only the internal Alertmanager - // We currently support only remote secondary mode, so in case other toggles are enabled we fall back to remote secondary. + // We currently do not support remote primary mode, so we fall back to remote secondary. var overrides []notifier.Option moaLogger := log.New("ngalert.multiorg.alertmanager") remoteOnly := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteOnly) @@ -172,7 +172,35 @@ func (ng *AlertNG) init() error { remoteSecondary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondary) if ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Enable { switch { - case remoteOnly, remotePrimary: + case remoteOnly: + ng.Log.Debug("Starting Grafana with remote only mode enabled") + m := ng.Metrics.GetRemoteAlertmanagerMetrics() + m.Info.WithLabelValues(metrics.ModeRemoteOnly).Set(1) + + // This function will be used by the MOA to create new Alertmanagers. + override := notifier.WithAlertmanagerOverride(func(_ notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory { + return func(ctx context.Context, orgID int64) (notifier.Alertmanager, error) { + // Create remote Alertmanager. + cfg := remote.AlertmanagerConfig{ + BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password, + DefaultConfig: ng.Cfg.UnifiedAlerting.DefaultConfiguration, + OrgID: orgID, + TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID, + URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL, + PromoteConfig: true, + } + remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, m) + if err != nil { + moaLogger.Error("Failed to create remote Alertmanager", "err", err) + return nil, err + } + return remoteAM, nil + } + }) + + overrides = append(overrides, override) + + case remotePrimary: ng.Log.Warn("Only remote secondary mode is supported at the moment, falling back to remote secondary") fallthrough From 6d98f53e8b12f1cfa0166e0c46db1059d13bf8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Mon, 27 May 2024 17:47:57 +0200 Subject: [PATCH 02/12] Chore: Remove `returnToPrevious` feature toggle (#88332) --- .../configure-grafana/feature-toggles/index.md | 1 - e2e/various-suite/return-to-previous.spec.ts | 3 --- packages/grafana-data/src/types/featureToggles.gen.ts | 1 - pkg/services/featuremgmt/registry.go | 8 -------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- public/app/core/components/AppChrome/AppChrome.tsx | 4 +--- public/app/core/components/AppChrome/AppChromeService.tsx | 8 -------- .../AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx | 7 +------ .../unified/components/rules/RuleDetailsButtons.tsx | 5 +---- 11 files changed, 5 insertions(+), 40 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 7c288821375..193edc72016 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -28,7 +28,6 @@ For more information about feature release stages, refer to [Release life cycle | `featureHighlights` | Highlight Grafana Enterprise features | | | `correlations` | Correlations page | Yes | | `exploreContentOutline` | Content outline sidebar | Yes | -| `returnToPrevious` | Enables the return to previous context functionality | Yes | | `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | | `nestedFolders` | Enable folder nesting | Yes | | `nestedFolderPicker` | Enables the new folder picker to work with nested folders. Requires the nestedFolders feature toggle | Yes | diff --git a/e2e/various-suite/return-to-previous.spec.ts b/e2e/various-suite/return-to-previous.spec.ts index bc47efc5c57..395c233d76a 100644 --- a/e2e/various-suite/return-to-previous.spec.ts +++ b/e2e/various-suite/return-to-previous.spec.ts @@ -3,9 +3,6 @@ import { e2e } from '../utils'; describe('ReturnToPrevious button', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'returnToPrevious=1'); - }); cy.visit('/alerting/list'); e2e.components.AlertRules.groupToggle().first().click(); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1074b64cd1b..2f78d31a40e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -44,7 +44,6 @@ export interface FeatureToggles { disableSecretsCompatibility?: boolean; logRequestsInstrumentedAsUnknown?: boolean; topnav?: boolean; - returnToPrevious?: boolean; grpcServer?: boolean; unifiedStorage?: boolean; dualWritePlaylistsMode2?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 19f25677309..3779a22cf4b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -206,14 +206,6 @@ var ( Expression: "true", // enabled by default Owner: grafanaFrontendPlatformSquad, }, - { - Name: "returnToPrevious", - Description: "Enables the return to previous context functionality", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Expression: "true", // enabled by default - Owner: grafanaFrontendPlatformSquad, - }, { Name: "grpcServer", Description: "Run the GRPC server", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6a71506c26b..4b830d9866b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -25,7 +25,6 @@ scenes,experimental,@grafana/dashboards-squad,false,false,true disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,true,false logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false -returnToPrevious,GA,@grafana/grafana-frontend-platform,false,false,true grpcServer,preview,@grafana/grafana-app-platform-squad,false,false,false unifiedStorage,experimental,@grafana/grafana-app-platform-squad,false,true,false dualWritePlaylistsMode2,experimental,@grafana/search-and-storage,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 174f0332b7e..8860ce21bde 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -111,10 +111,6 @@ const ( // Enables topnav support in external plugins. The new Grafana navigation cannot be disabled. FlagTopnav = "topnav" - // FlagReturnToPrevious - // Enables the return to previous context functionality - FlagReturnToPrevious = "returnToPrevious" - // FlagGrpcServer // Run the GRPC server FlagGrpcServer = "grpcServer" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 02a2f7175c8..4665163f218 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -60,7 +60,8 @@ "metadata": { "name": "returnToPrevious", "resourceVersion": "1716448665531", - "creationTimestamp": "2024-05-23T07:17:45Z" + "creationTimestamp": "2024-05-23T07:17:45Z", + "deletionTimestamp": "2024-05-27T09:59:33Z" }, "spec": { "description": "Enables the return to previous context functionality", diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index dc7492db630..6dcfe645b03 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -5,7 +5,6 @@ import React, { PropsWithChildren, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { locationSearchToObject, locationService } from '@grafana/runtime'; import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui'; -import config from 'app/core/config'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import store from 'app/core/store'; @@ -55,8 +54,7 @@ export function AppChrome({ children }: Props) { const { pathname, search } = locationService.getLocation(); const url = pathname + search; - const shouldShowReturnToPrevious = - config.featureToggles.returnToPrevious && state.returnToPrevious && url !== state.returnToPrevious.href; + const shouldShowReturnToPrevious = state.returnToPrevious && url !== state.returnToPrevious.href; // Clear returnToPrevious when the page is manually navigated to useEffect(() => { diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 647e21bb638..7a062e1152f 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -90,10 +90,6 @@ export class AppChromeService { } public setReturnToPrevious = (returnToPrevious: ReturnToPreviousProps) => { - const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious; - if (!isReturnToPreviousEnabled) { - return; - } const previousPage = this.state.getValue().returnToPrevious; reportInteraction('grafana_return_to_previous_button_created', { page: returnToPrevious.href, @@ -105,10 +101,6 @@ export class AppChromeService { }; public clearReturnToPrevious = (interactionAction: 'clicked' | 'dismissed' | 'auto_dismissed') => { - const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious; - if (!isReturnToPreviousEnabled) { - return; - } const existingRtp = this.state.getValue().returnToPrevious; if (existingRtp) { reportInteraction('grafana_return_to_previous_button_dismissed', { diff --git a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx index 0f9b5d62c94..b016e4ba626 100644 --- a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx +++ b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; -import { config, reportInteraction } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { ReturnToPrevious, ReturnToPreviousProps } from './ReturnToPrevious'; @@ -36,14 +36,9 @@ const setup = () => { }; describe('ReturnToPrevious', () => { - beforeEach(() => { - /* We enabled the feature toggle */ - config.featureToggles.returnToPrevious = true; - }); afterEach(() => { window.sessionStorage.clear(); jest.resetAllMocks(); - config.featureToggles.returnToPrevious = false; }); it('should render component', async () => { setup(); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx index 2008d401a77..71184ae8b0f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import { textUtil } from '@grafana/data'; -import { config, useReturnToPrevious } from '@grafana/runtime'; +import { useReturnToPrevious } from '@grafana/runtime'; import { Button, LinkButton, Stack } from '@grafana/ui'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; @@ -68,7 +68,6 @@ const RuleDetailsButtons = ({ rule, rulesSource }: Props) => { } if (rule.annotations[Annotation.dashboardUID]) { const dashboardUID = rule.annotations[Annotation.dashboardUID]; - const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious; if (dashboardUID) { buttons.push( { key="dashboard" variant="primary" icon="apps" - target={isReturnToPreviousEnabled ? undefined : '_blank'} href={`d/${encodeURIComponent(dashboardUID)}`} onClick={() => { setReturnToPrevious(rule.name); @@ -93,7 +91,6 @@ const RuleDetailsButtons = ({ rule, rulesSource }: Props) => { key="panel" variant="primary" icon="apps" - target={isReturnToPreviousEnabled ? undefined : '_blank'} href={`d/${encodeURIComponent(dashboardUID)}?viewPanel=${encodeURIComponent(panelId)}`} onClick={() => { setReturnToPrevious(rule.name); From 39298a656a3f75d390340a89d795b59b6bca4b10 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue, 28 May 2024 01:12:54 +0300 Subject: [PATCH 03/12] Swagger: sync specs with enterprise (#88355) --- public/api-enterprise-spec.json | 37 +++++++++++++++++++++++++++++++++ public/api-merged.json | 34 ++++++++++++++++++++++++++++++ public/openapi3.json | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index feff3822aba..542bd383f9e 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -760,6 +760,43 @@ } } }, + "/datasources/uid/{uid}/lbac/teams": { + "put": { + "tags": [ + "enterprise" + ], + "summary": "Updates LBAC rules for a team.", + "operationId": "updateTeamLBACRulesApi", + "parameters": [ + { + "type": "string", + "name": "uid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/datasources/{dataSourceUID}/cache": { "get": { "description": "get cache config for a single data source", diff --git a/public/api-merged.json b/public/api-merged.json index 252702e244b..dea0a108fd2 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -4315,6 +4315,40 @@ } } }, + "/datasources/uid/{uid}/lbac/teams": { + "get": { + "summary": "Retrieves LBAC rules for a team.", + "operationId": "getTeamLBACRulesApiResponse", + "parameters": [ + { + "type": "string", + "name": "uid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/datasources/uid/{uid}/resources/{datasource_proxy_route}": { "get": { "tags": [ diff --git a/public/openapi3.json b/public/openapi3.json index 07b2eb62805..379292512af 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -17143,6 +17143,42 @@ ] } }, + "/datasources/uid/{uid}/lbac/teams": { + "get": { + "operationId": "getTeamLBACRulesApiResponse", + "parameters": [ + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Retrieves LBAC rules for a team." + } + }, "/datasources/uid/{uid}/resources/{datasource_proxy_route}": { "get": { "operationId": "callDatasourceResourceWithUID", From 0b526acf09c06b2604031fc3ab3474236f356ea6 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Tue, 28 May 2024 00:47:45 +0200 Subject: [PATCH 04/12] Fix provisioning for a missing plugin (#88354) --- pkg/services/datasources/service/datasource.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 52145be4ded..ee8f61093b9 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -315,8 +315,8 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, pluginContext bac // Make sure it is a known plugin type p, found := s.pluginStore.Plugin(ctx, settings.Type) if !found { - return nil, errutil.BadRequest("datasource.unknownPlugin", - errutil.WithPublicMessage(fmt.Sprintf("plugin '%s' not found", settings.Type))) + // Ignore non-existing plugins for the time being + return settings, nil } // When the APIVersion is set, the client must also implement AdmissionHandler From f7720562968413d8e1d569b3544803c340629661 Mon Sep 17 00:00:00 2001 From: gng0 <157993762+gng0@users.noreply.github.com> Date: Tue, 28 May 2024 18:16:55 +1200 Subject: [PATCH 05/12] DataLinks: Fixes datalinks with onClick and variables in url not being interpolated (#86253) * Use replaceVariables method on link url, fix and add unit tests * Refactor href processing for links * Prettier --- .../src/field/fieldOverrides.test.ts | 49 +++++++++++++++++-- .../grafana-data/src/field/fieldOverrides.ts | 29 +++++------ 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 67426e6f538..95d8f9fd9c1 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -971,7 +971,7 @@ describe('getLinksSupplier', () => { }); it('handles link click handlers', () => { const onClickSpy = jest.fn(); - const replaceSpy = jest.fn(); + const replaceSpy = jest.fn().mockImplementation((value, vars, format) => value); const f0 = createDataFrame({ name: 'A', fields: [ @@ -1008,8 +1008,8 @@ describe('getLinksSupplier', () => { links[0].onClick!({}); - expect(onClickSpy).toBeCalledTimes(1); - expect(replaceSpy).toBeCalledTimes(4); + expect(onClickSpy).toHaveBeenCalledTimes(1); + expect(replaceSpy).toHaveBeenCalledTimes(5); // check that onClick variable replacer has scoped vars bound to it expect(replaceSpy.mock.calls[1][1]).toHaveProperty('foo', { text: 'bar', value: 'bar' }); }); @@ -1057,6 +1057,49 @@ describe('getLinksSupplier', () => { expect(replaceSpy.mock.calls[1][1]).toHaveProperty('foo', { text: 'bar', value: 'bar' }); }); }); + + it('handles dynamic links with onclick handler', () => { + const replaceSpy = jest.fn().mockReturnValue('url interpolated 10'); + const onClickUrlSpy = jest.fn(); + const scopedVars = { foo: { text: 'bar', value: 'bar' } }; + const f0 = createDataFrame({ + name: 'A', + fields: [ + { + name: 'message', + type: FieldType.string, + config: { + links: [ + { + url: 'should be ignored', + onClick: (evt) => { + onClickUrlSpy(); + evt.replaceVariables?.('${foo}'); + }, + title: 'title to be interpolated', + }, + { + url: 'should not be ignored', + title: 'title to be interpolated', + }, + ], + }, + }, + ], + }); + + const supplier = getLinksSupplier(f0, f0.fields[0], scopedVars, replaceSpy); + const links = supplier({}); + links[0].onClick!({}); + + expect(onClickUrlSpy).toHaveBeenCalledTimes(1); + expect(links.length).toBe(2); + expect(links[0].href).toEqual('url interpolated 10'); + expect(links[0].onClick).toBeDefined(); + expect(replaceSpy).toHaveBeenCalledTimes(5); + // check that onClick variable replacer has scoped vars bound to it + expect(replaceSpy.mock.calls[1][1]).toHaveProperty('foo', { text: 'bar', value: 'bar' }); + }); }); describe('applyRawFieldOverrides', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 4047a589d96..e0cdf966f06 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -468,9 +468,23 @@ export const getLinksSupplier = let linkModel: LinkModel; + let href = + link.onClick || !link.onBuildUrl + ? link.url + : link.onBuildUrl({ + origin: field, + replaceVariables: boundReplaceVariables, + }); + + if (href) { + href = locationUtil.assureBaseUrl(href.replace(/\n/g, '')); + href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.UriEncode); + href = locationUtil.processUrl(href); + } + if (link.onClick) { linkModel = { - href: link.url, + href, title: replaceVariables(link.title || '', dataLinkScopedVars), target: link.targetBlank ? '_blank' : undefined, onClick: (evt: MouseEvent, origin: Field) => { @@ -483,19 +497,6 @@ export const getLinksSupplier = origin: field, }; } else { - let href = link.onBuildUrl - ? link.onBuildUrl({ - origin: field, - replaceVariables: boundReplaceVariables, - }) - : link.url; - - if (href) { - href = locationUtil.assureBaseUrl(href.replace(/\n/g, '')); - href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.UriEncode); - href = locationUtil.processUrl(href); - } - linkModel = { href, title: replaceVariables(link.title || '', dataLinkScopedVars), From f6a83432a5f925b863b633ca3039525a7a76301f Mon Sep 17 00:00:00 2001 From: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Tue, 28 May 2024 10:26:06 +0200 Subject: [PATCH 06/12] RestoreDashboards: Create trash section (#88278) * feat: add feature toggle to docs * feat: create page in BE * feat: create page in FE * refactor: set feat toggle as a condition * refactor * refactor * refactor * feat: add permission conditions * refactor * feat: add subtitle to translations * feat: add to codeowners * refactor: fix docs situation * refactor * Merge main branch into feat branch --- .github/CODEOWNERS | 1 + .../feature-toggles/index.md | 1 + pkg/services/featuremgmt/registry.go | 1 - pkg/services/featuremgmt/toggles_gen.json | 59 ++++++++++++------- pkg/services/navtree/navtreeimpl/navtree.go | 9 +++ .../app/core/utils/navBarItem-translations.ts | 7 +++ .../app/features/trash-section/TrashPage.tsx | 15 +++++ public/app/routes/routes.tsx | 7 +++ public/locales/en-US/grafana.json | 4 ++ public/locales/pseudo-LOCALE/grafana.json | 4 ++ 10 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 public/app/features/trash-section/TrashPage.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 067eeda60ef..4a514aafdb9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -435,6 +435,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/features/transformers/timeSeriesTable/ @grafana/dataviz-squad @grafana/app-o11y-visualizations /public/app/features/users/ @grafana/identity-access-team /public/app/features/variables/ @grafana/dashboards-squad +/public/app/features/trash-section/ @grafana/grafana-frontend-platform /public/app/plugins/panel/alertlist/ @grafana/alerting-frontend /public/app/plugins/panel/annolist/ @grafana/grafana-frontend-platform /public/app/plugins/panel/barchart/ @grafana/dataviz-squad diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 193edc72016..1d19ed1f577 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -189,6 +189,7 @@ Experimental features might be changed or removed without prior notice. | `newDashboardSharingComponent` | Enables the new sharing drawer design | | `alertingListViewV2` | Enables the new alert list view design | | `notificationBanner` | Enables the notification banner UI and API | +| `dashboardRestore` | Enables deleted dashboard restore feature | ## Development feature toggles diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3779a22cf4b..25ed32b6d2d 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1279,7 +1279,6 @@ var ( Description: "Enables deleted dashboard restore feature", Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, - HideFromDocs: true, HideFromAdminPage: true, }, { diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4665163f218..9e750c36983 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -588,15 +588,17 @@ { "metadata": { "name": "dashboardRestore", - "resourceVersion": "1716448665531", - "creationTimestamp": "2024-05-23T07:17:45Z" + "resourceVersion": "1716564259132", + "creationTimestamp": "2024-05-23T07:17:45Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-05-24 15:24:19.132272 +0000 UTC" + } }, "spec": { "description": "Enables deleted dashboard restore feature", "stage": "experimental", "codeowner": "@grafana/grafana-frontend-platform", - "hideFromAdminPage": true, - "hideFromDocs": true + "hideFromAdminPage": true } }, { @@ -2204,26 +2206,19 @@ }, { "metadata": { - "name": "influxdbRunQueriesInParallel", - "resourceVersion": "1716448665531", - "creationTimestamp": "2024-05-23T07:17:45Z" - }, - "spec": { - "description": "Enables running InfluxDB Influxql queries in parallel", - "stage": "privatePreview", - "codeowner": "@grafana/observability-metrics" - } - }, - { - "metadata": { - "name": "disableSSEDataplane", - "resourceVersion": "1716448665531", - "creationTimestamp": "2024-05-23T07:17:45Z" + "name": "dashboardRestore", + "resourceVersion": "1716563559003", + "creationTimestamp": "2024-02-20T18:50:41Z", + "deletionTimestamp": "2024-05-24T15:24:19Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-05-24 15:12:39.003245 +0000 UTC" + } }, "spec": { "description": "Disables dataplane specific processing in server side expressions.", "stage": "experimental", - "codeowner": "@grafana/observability-metrics" + "codeowner": "@grafana/grafana-frontend-platform", + "hideFromAdminPage": true } }, { @@ -2265,6 +2260,30 @@ "hideFromAdminPage": true, "hideFromDocs": true } + }, + { + "metadata": { + "name": "disableSSEDataplane", + "resourceVersion": "1716816471156", + "creationTimestamp": "2024-05-27T13:27:51Z" + }, + "spec": { + "description": "Disables dataplane specific processing in server side expressions.", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics" + } + }, + { + "metadata": { + "name": "influxdbRunQueriesInParallel", + "resourceVersion": "1716816471156", + "creationTimestamp": "2024-05-27T13:27:51Z" + }, + "spec": { + "description": "Enables running InfluxDB Influxql queries in parallel", + "stage": "privatePreview", + "codeowner": "@grafana/observability-metrics" + } } ] } \ No newline at end of file diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 4647808a04c..826d367c82f 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -353,6 +353,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt Icon: "library-panel", }) } + + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDashboardRestore) && hasAccess(ac.EvalPermission(dashboards.ActionDashboardsDelete)) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Trash", + SubTitle: "Any items remaining in the Trash for more than 30 days will be automatically deleted", + Id: "dashboards/trash", + Url: s.cfg.AppSubURL + "/dashboard/trash", + }) + } } if hasAccess(ac.EvalPermission(dashboards.ActionDashboardsCreate)) { diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index faafa9b00f9..9a7dc478d30 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -40,6 +40,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.reporting.title', 'Reporting'); case 'dashboards/public': return t('nav.public.title', 'Public dashboards'); + case 'dashboards/trash': + return t('nav.trash.title', 'Trash'); case 'dashboards/new': return t('nav.new-dashboard.title', 'New dashboard'); case 'dashboards/folder/new': @@ -206,6 +208,11 @@ export function getNavSubTitle(navId: string | undefined) { ); case 'dashboards/library-panels': return t('nav.library-panels.subtitle', 'Reusable panels that can be added to multiple dashboards'); + case 'dashboards/trash': + return t( + 'nav.trash.subtitle', + 'Any items remaining in the Trash for more than 30 days will be automatically deleted' + ); case 'alerting': return t('nav.alerting.subtitle', 'Learn about problems in your systems moments after they occur'); case 'alerting-upgrade': diff --git a/public/app/features/trash-section/TrashPage.tsx b/public/app/features/trash-section/TrashPage.tsx new file mode 100644 index 00000000000..cfeca460d7e --- /dev/null +++ b/public/app/features/trash-section/TrashPage.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { Page } from 'app/core/components/Page/Page'; + +const TrashPage = () => { + return ( + + +

page content

+
+
+ ); +}; + +export default TrashPage; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 423e3ce9fff..1df805c21d4 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -435,6 +435,13 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "SnapshotListPage" */ 'app/features/manage-dashboards/SnapshotListPage') ), }, + config.featureToggles.dashboardRestore && { + path: '/dashboard/trash', + roles: () => contextSrv.evaluatePermission([AccessControlAction.DashboardsDelete]), + component: SafeDynamicImport( + () => import(/* webpackChunkName: "TrashPage" */ 'app/features/trash-section/TrashPage') + ), + }, { path: '/playlists', component: SafeDynamicImport( diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bb7a7713ae4..ed87ba04fed 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1179,6 +1179,10 @@ "subtitle": "Optimize performance with k6 and Synthetic Monitoring insights", "title": "Testing & synthetics" }, + "trash": { + "subtitle": "Any items remaining in the Trash for more than 30 days will be automatically deleted", + "title": "Trash" + }, "upgrading": { "title": "Stats and license" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 59fc5847e61..492ed9100a4 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1179,6 +1179,10 @@ "subtitle": "Øpŧįmįžę pęřƒőřmäʼnčę ŵįŧĥ ĸ6 äʼnđ Ŝyʼnŧĥęŧįč Mőʼnįŧőřįʼnģ įʼnşįģĥŧş", "title": "Ŧęşŧįʼnģ & şyʼnŧĥęŧįčş" }, + "trash": { + "subtitle": "Åʼny įŧęmş řęmäįʼnįʼnģ įʼn ŧĥę Ŧřäşĥ ƒőř mőřę ŧĥäʼn 30 đäyş ŵįľľ þę äūŧőmäŧįčäľľy đęľęŧęđ", + "title": "Ŧřäşĥ" + }, "upgrading": { "title": "Ŝŧäŧş äʼnđ ľįčęʼnşę" }, From 3d9908f36316c00c53e766753ebfe8cdc77324cb Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Tue, 28 May 2024 10:39:46 +0200 Subject: [PATCH 07/12] Fix: Prevent ExtSvcTokens from containing nil characters (#88243) * Fix: Prevent ExtSvcTokens from containing nil characters * Rebase * Add more logs * Nit. nil -> NUL * Nit. Part -> Parts * Back to const * Account for comments Co-authored-by: Claudiu Dragalina-Paraipan --------- Co-authored-by: Claudiu Dragalina-Paraipan --- .../pluginconfig/envvars.go | 2 +- .../serviceaccounts/extsvcaccounts/models.go | 17 +++--- .../serviceaccounts/extsvcaccounts/service.go | 52 ++++++++++++++++++- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/pkg/services/pluginsintegration/pluginconfig/envvars.go b/pkg/services/pluginsintegration/pluginconfig/envvars.go index 2871ece5789..fe3579661e1 100644 --- a/pkg/services/pluginsintegration/pluginconfig/envvars.go +++ b/pkg/services/pluginsintegration/pluginconfig/envvars.go @@ -179,7 +179,7 @@ func (p *EnvVarsProvider) pluginSettingsEnvVars(pluginID string) []string { func (p *EnvVarsProvider) envVar(key, value string) string { if strings.Contains(value, "\x00") { - p.logger.Error("Variable with key '%s' contains a nil character", key) + p.logger.Error("Variable with key '%s' contains NUL", key) } return fmt.Sprintf("%s=%s", key, value) } diff --git a/pkg/services/serviceaccounts/extsvcaccounts/models.go b/pkg/services/serviceaccounts/extsvcaccounts/models.go index f6ab4ee4636..fd74e821e7f 100644 --- a/pkg/services/serviceaccounts/extsvcaccounts/models.go +++ b/pkg/services/serviceaccounts/extsvcaccounts/models.go @@ -15,16 +15,19 @@ const ( kvStoreType = "extsvc-token" // #nosec G101 - this is not a hardcoded secret tokenNamePrefix = "extsvc-token" + + maxTokenGenRetries = 10 ) var ( - ErrCannotBeDeleted = errutil.BadRequest("extsvcaccounts.ErrCannotBeDeleted", errutil.WithPublicMessage("external service account cannot be deleted")) - ErrCannotBeUpdated = errutil.BadRequest("extsvcaccounts.ErrCannotBeUpdated", errutil.WithPublicMessage("external service account cannot be updated")) - ErrCannotCreateToken = errutil.BadRequest("extsvcaccounts.ErrCannotCreateToken", errutil.WithPublicMessage("cannot add external service account token")) - ErrCannotDeleteToken = errutil.BadRequest("extsvcaccounts.ErrCannotDeleteToken", errutil.WithPublicMessage("cannot delete external service account token")) - ErrCannotListTokens = errutil.BadRequest("extsvcaccounts.ErrCannotListTokens", errutil.WithPublicMessage("cannot list external service account tokens")) - ErrCredentialsNotFound = errutil.NotFound("extsvcaccounts.credentials-not-found") - ErrInvalidName = errutil.BadRequest("extsvcaccounts.ErrInvalidName", errutil.WithPublicMessage("only external service account names can be prefixed with 'extsvc-'")) + ErrCannotBeDeleted = errutil.BadRequest("extsvcaccounts.ErrCannotBeDeleted", errutil.WithPublicMessage("external service account cannot be deleted")) + ErrCannotBeUpdated = errutil.BadRequest("extsvcaccounts.ErrCannotBeUpdated", errutil.WithPublicMessage("external service account cannot be updated")) + ErrCannotCreateToken = errutil.BadRequest("extsvcaccounts.ErrCannotCreateToken", errutil.WithPublicMessage("cannot add external service account token")) + ErrCannotDeleteToken = errutil.BadRequest("extsvcaccounts.ErrCannotDeleteToken", errutil.WithPublicMessage("cannot delete external service account token")) + ErrCannotListTokens = errutil.BadRequest("extsvcaccounts.ErrCannotListTokens", errutil.WithPublicMessage("cannot list external service account tokens")) + ErrCredentialsGenFailed = errutil.Internal("extsvcaccounts.ErrCredentialsGenFailed") + ErrCredentialsNotFound = errutil.NotFound("extsvcaccounts.ErrCredentialsNotFound") + ErrInvalidName = errutil.BadRequest("extsvcaccounts.ErrInvalidName", errutil.WithPublicMessage("only external service account names can be prefixed with 'extsvc-'")) extsvcuser = &user.SignedInUser{ OrgID: extsvcauth.TmpOrgID, diff --git a/pkg/services/serviceaccounts/extsvcaccounts/service.go b/pkg/services/serviceaccounts/extsvcaccounts/service.go index 3c722a8c19f..d7b462088e2 100644 --- a/pkg/services/serviceaccounts/extsvcaccounts/service.go +++ b/pkg/services/serviceaccounts/extsvcaccounts/service.go @@ -355,7 +355,7 @@ func (esa *ExtSvcAccountsService) getExtSvcAccountToken(ctx context.Context, org // Generate token ctxLogger.Info("Generate new service account token", "service", extSvcSlug, "orgID", orgID) - newKeyInfo, err := satokengen.New(extSvcSlug) + newKeyInfo, err := genTokenWithRetries(ctxLogger, extSvcSlug) if err != nil { return "", err } @@ -380,6 +380,52 @@ func (esa *ExtSvcAccountsService) getExtSvcAccountToken(ctx context.Context, org return newKeyInfo.ClientSecret, nil } +func genTokenWithRetries(ctxLogger log.Logger, extSvcSlug string) (satokengen.KeyGenResult, error) { + var newKeyInfo satokengen.KeyGenResult + var err error + retry := 0 + for retry < maxTokenGenRetries { + newKeyInfo, err = satokengen.New(extSvcSlug) + if err != nil { + return satokengen.KeyGenResult{}, err + } + + if !strings.Contains(newKeyInfo.ClientSecret, "\x00") { + return newKeyInfo, nil + } + + retry++ + + ctxLogger.Warn("Generated a token containing NUL, retrying", + "service", extSvcSlug, + "retry", retry, + ) + // On first retry, log the token parts that contain a nil byte + if retry == 1 { + logTokenNULParts(ctxLogger, extSvcSlug, newKeyInfo.ClientSecret) + } + } + + return satokengen.KeyGenResult{}, ErrCredentialsGenFailed.Errorf("Failed to generate a token for %s", extSvcSlug) +} + +// logTokenNULParts logs a warning if the external service token contains a nil byte +// Tokens normally have 3 parts "gl+serviceID_secret_checksum" +// Log the part of the generated token that contains a nil byte +func logTokenNULParts(ctxLogger log.Logger, extSvcSlug string, token string) { + parts := strings.Split(token, "_") + for i := range parts { + if strings.Contains(parts[i], "\x00") { + ctxLogger.Warn("Token contains NUL", + "service", extSvcSlug, + "part", i, + "part_len", len(parts[i]), + "parts_count", len(parts), + ) + } + } +} + // GetExtSvcCredentials get the credentials of an External Service from an encrypted storage func (esa *ExtSvcAccountsService) GetExtSvcCredentials(ctx context.Context, orgID int64, extSvcSlug string) (*Credentials, error) { ctxLogger := esa.logger.FromContext(ctx) @@ -391,6 +437,10 @@ func (esa *ExtSvcAccountsService) GetExtSvcCredentials(ctx context.Context, orgI if !ok { return nil, ErrCredentialsNotFound.Errorf("No credential found for in store %v", extSvcSlug) } + if strings.Contains(token, "\x00") { + ctxLogger.Warn("Loaded token from store containing NUL", "service", extSvcSlug) + logTokenNULParts(ctxLogger, extSvcSlug, token) + } return &Credentials{Secret: token}, nil } From 6f9527b4c2c5b4115c8fd925123b8b4c3d70e0d1 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 28 May 2024 09:46:04 +0100 Subject: [PATCH 08/12] DashboardGrid: Ensure drag handle always follows the cursor (#88099) use flushSync in react-grid-layout --- ...act-grid-layout-npm-1.4.4-4024c5395b.patch | 121 ++++++++++++++++++ package.json | 5 +- yarn.lock | 46 +++---- 3 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 .yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch diff --git a/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch b/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch new file mode 100644 index 00000000000..db47c9be1f2 --- /dev/null +++ b/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch @@ -0,0 +1,121 @@ +diff --git a/build/GridItem.js b/build/GridItem.js +index 0a700da9f1180ca532e32e04dc7ea50f2e67b96c..a2e4673fa1133aeaa4018cc01312ca386c3395f5 100644 +--- a/build/GridItem.js ++++ b/build/GridItem.js +@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", { + }); + exports.default = void 0; + var _react = _interopRequireDefault(require("react")); ++var _reactDOM = require("react-dom"); + var _propTypes = _interopRequireDefault(require("prop-types")); + var _reactDraggable = require("react-draggable"); + var _reactResizable = require("react-resizable"); +@@ -147,8 +148,10 @@ class GridItem extends _react.default.Component /*:: */{ + const pTop = parentRect.top / transformScale; + newPosition.left = cLeft - pLeft + offsetParent.scrollLeft; + newPosition.top = cTop - pTop + offsetParent.scrollTop; +- this.setState({ +- dragging: newPosition ++ _reactDOM.flushSync(() => { ++ this.setState({ ++ dragging: newPosition ++ }); + }); + + // Call callback with this data +@@ -213,8 +216,10 @@ class GridItem extends _react.default.Component /*:: */{ + top, + left + }; +- this.setState({ +- dragging: newPosition ++ _reactDOM.flushSync(() => { ++ this.setState({ ++ dragging: newPosition ++ }); + }); + + // Call callback with this data +@@ -261,8 +266,10 @@ class GridItem extends _react.default.Component /*:: */{ + top, + left + }; +- this.setState({ +- dragging: null ++ _reactDOM.flushSync(() => { ++ this.setState({ ++ dragging: null ++ }); + }); + const { + x, +@@ -485,8 +492,10 @@ class GridItem extends _react.default.Component /*:: */{ + let updatedSize = size; + if (node) { + updatedSize = (0, _utils.resizeItemInDirection)(handle, position, size, containerWidth); +- this.setState({ +- resizing: handlerName === "onResizeStop" ? null : updatedSize ++ _reactDOM.flushSync(() => { ++ this.setState({ ++ resizing: handlerName === "onResizeStop" ? null : updatedSize ++ }); + }); + } + +diff --git a/lib/GridItem.jsx b/lib/GridItem.jsx +index dbe41f92388f19d3e476690fa0ee5584ab9d5bb4..1e4713667cd7dadd6618fe06176804a02ee3ccc2 100644 +--- a/lib/GridItem.jsx ++++ b/lib/GridItem.jsx +@@ -1,5 +1,6 @@ + // @flow + import React from "react"; ++import { flushSync } from "react-dom"; + import PropTypes from "prop-types"; + import { DraggableCore } from "react-draggable"; + import { Resizable } from "react-resizable"; +@@ -459,7 +460,9 @@ export default class GridItem extends React.Component { + const pTop = parentRect.top / transformScale; + newPosition.left = cLeft - pLeft + offsetParent.scrollLeft; + newPosition.top = cTop - pTop + offsetParent.scrollTop; +- this.setState({ dragging: newPosition }); ++ flushSync(() => { ++ this.setState({ dragging: newPosition }); ++ }); + + // Call callback with this data + const { x, y } = calcXY( +@@ -516,7 +519,9 @@ export default class GridItem extends React.Component { + } + + const newPosition: PartialPosition = { top, left }; +- this.setState({ dragging: newPosition }); ++ flushSync(() => { ++ this.setState({ dragging: newPosition }); ++ }); + + // Call callback with this data + const { containerPadding } = this.props; +@@ -549,7 +554,9 @@ export default class GridItem extends React.Component { + const { w, h, i, containerPadding } = this.props; + const { left, top } = this.state.dragging; + const newPosition: PartialPosition = { top, left }; +- this.setState({ dragging: null }); ++ flushSync(() => { ++ this.setState({ dragging: null }); ++ }); + + const { x, y } = calcXY( + this.getPositionParams(), +@@ -605,8 +612,10 @@ export default class GridItem extends React.Component { + size, + containerWidth + ); +- this.setState({ +- resizing: handlerName === "onResizeStop" ? null : updatedSize ++ flushSync(() => { ++ this.setState({ ++ resizing: handlerName === "onResizeStop" ? null : updatedSize ++ }); + }); + } + diff --git a/package.json b/package.json index 6ac0227edbf..0831fdf13f5 100644 --- a/package.json +++ b/package.json @@ -354,7 +354,7 @@ "react-dom": "18.2.0", "react-draggable": "4.4.6", "react-dropzone": "^14.2.3", - "react-grid-layout": "1.4.4", + "react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch", "react-highlight-words": "0.20.0", "react-hook-form": "^7.49.2", "react-i18next": "^14.0.0", @@ -414,7 +414,8 @@ "history@4.10.1": "patch:history@npm%3A4.10.1#./.yarn/patches/history-npm-4.10.1-ee217563ae.patch", "history@^4.9.0": "patch:history@npm%3A4.10.1#./.yarn/patches/history-npm-4.10.1-ee217563ae.patch", "redux": "^5.0.0", - "@storybook/blocks@npm:8.0.10": "patch:@storybook/blocks@npm%3A8.0.10#~/.yarn/patches/@storybook-blocks-npm-8.0.10-6f477cd35f.patch" + "@storybook/blocks@npm:8.0.10": "patch:@storybook/blocks@npm%3A8.0.10#~/.yarn/patches/@storybook-blocks-npm-8.0.10-6f477cd35f.patch", + "react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch" }, "workspaces": { "packages": [ diff --git a/yarn.lock b/yarn.lock index ab34cf20422..c53990fd397 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16943,7 +16943,7 @@ __metadata: react-dom: "npm:18.2.0" react-draggable: "npm:4.4.6" react-dropzone: "npm:^14.2.3" - react-grid-layout: "npm:1.4.4" + react-grid-layout: "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch" react-highlight-words: "npm:0.20.0" react-hook-form: "npm:^7.49.2" react-i18next: "npm:^14.0.0" @@ -20251,13 +20251,6 @@ __metadata: languageName: node linkType: hard -"lodash.isequal@npm:^4.0.0": - version: 4.5.0 - resolution: "lodash.isequal@npm:4.5.0" - checksum: 10/82fc58a83a1555f8df34ca9a2cd300995ff94018ac12cc47c349655f0ae1d4d92ba346db4c19bbfc90510764e0c00ddcc985a358bdcd4b3b965abf8f2a48a214 - languageName: node - linkType: hard - "lodash.ismatch@npm:^4.4.0": version: 4.4.0 resolution: "lodash.ismatch@npm:4.4.0" @@ -24784,7 +24777,7 @@ __metadata: languageName: node linkType: hard -"react-draggable@npm:4.4.6, react-draggable@npm:^4.0.0, react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5": +"react-draggable@npm:4.4.6, react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5": version: 4.4.6 resolution: "react-draggable@npm:4.4.6" dependencies: @@ -24860,22 +24853,6 @@ __metadata: languageName: node linkType: hard -"react-grid-layout@npm:1.3.4": - version: 1.3.4 - resolution: "react-grid-layout@npm:1.3.4" - dependencies: - clsx: "npm:^1.1.1" - lodash.isequal: "npm:^4.0.0" - prop-types: "npm:^15.8.1" - react-draggable: "npm:^4.0.0" - react-resizable: "npm:^3.0.4" - peerDependencies: - react: ">= 16.3.0" - react-dom: ">= 16.3.0" - checksum: 10/944ab133e59bfaa5633625f57be9f69133b5cec2de0232d9581e2c988e257ebafe010ee9bbbff6c2754f9d7d8bb0053072bac103f20fc232be2a58e15d14fc64 - languageName: node - linkType: hard - "react-grid-layout@npm:1.4.4": version: 1.4.4 resolution: "react-grid-layout@npm:1.4.4" @@ -24893,6 +24870,23 @@ __metadata: languageName: node linkType: hard +"react-grid-layout@patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch": + version: 1.4.4 + resolution: "react-grid-layout@patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch::version=1.4.4&hash=e69ce6" + dependencies: + clsx: "npm:^2.0.0" + fast-equals: "npm:^4.0.3" + prop-types: "npm:^15.8.1" + react-draggable: "npm:^4.4.5" + react-resizable: "npm:^3.0.5" + resize-observer-polyfill: "npm:^1.5.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10/fcc4fe40e38d723870dbc138af9a4a58dff6459efd191d03a66de2e663e169591a66d77b0f377bad780f64f4e24ca41735e4b2ecd99ae7c48071a0b8a1876a25 + languageName: node + linkType: hard + "react-highlight-words@npm:0.20.0": version: 0.20.0 resolution: "react-highlight-words@npm:0.20.0" @@ -25117,7 +25111,7 @@ __metadata: languageName: node linkType: hard -"react-resizable@npm:3.0.5, react-resizable@npm:^3.0.4, react-resizable@npm:^3.0.5": +"react-resizable@npm:3.0.5, react-resizable@npm:^3.0.5": version: 3.0.5 resolution: "react-resizable@npm:3.0.5" dependencies: From 97cb92eb1e1cf2a4ecb383ba9aa9401c83dd47da Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 28 May 2024 10:59:11 +0200 Subject: [PATCH 09/12] Logs: Added multi-line display control to the "wrap lines" option (#88144) * LogRowMessage: strip new lines when wrapping is disabled * LogRowMessage: prevent conflicts between failed json parsing and unwrapping * LogRowMessage: expand multi-line unwrapped logs when opening details * Add unit test * Prettier * chore: update comment --- .../app/features/logs/components/LogRow.tsx | 1 + .../logs/components/LogRowMessage.test.tsx | 37 +++++++++++++++++++ .../logs/components/LogRowMessage.tsx | 22 ++++++++--- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index 91ec154e005..163b53f2bec 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -308,6 +308,7 @@ class UnThemedLogRow extends PureComponent { pinned={this.props.pinned} mouseIsOver={this.state.mouseIsOver} onBlur={this.onMouseLeave} + expanded={this.state.showDetails} /> )} diff --git a/public/app/features/logs/components/LogRowMessage.test.tsx b/public/app/features/logs/components/LogRowMessage.test.tsx index a30448748b1..51ba688c9de 100644 --- a/public/app/features/logs/components/LogRowMessage.test.tsx +++ b/public/app/features/logs/components/LogRowMessage.test.tsx @@ -160,4 +160,41 @@ describe('LogRowMessage', () => { }); }); }); + + describe('For multi-line logs', () => { + const entry = `Line1 +line2 +line3`; + const singleLineEntry = entry.replace(/(\r\n|\n|\r)/g, ''); + it('Displays the original log line when wrapping is enabled', () => { + setup({ + row: createLogRow({ entry, logLevel: LogLevel.error, timeEpochMs: 1546297200000 }), + wrapLogMessage: true, + }); + expect(screen.getByText(/Line1/)).toBeInTheDocument(); + expect(screen.getByText(/line2/)).toBeInTheDocument(); + expect(screen.getByText(/line3/)).toBeInTheDocument(); + expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument(); + }); + + it('Removes new lines from the original log line when wrapping is disabled', () => { + setup({ + row: createLogRow({ entry, logLevel: LogLevel.error, timeEpochMs: 1546297200000 }), + wrapLogMessage: false, + }); + expect(screen.getByText(singleLineEntry)).toBeInTheDocument(); + }); + + it('Displays the original log line when the line is expanded', () => { + setup({ + row: createLogRow({ entry, logLevel: LogLevel.error, timeEpochMs: 1546297200000 }), + wrapLogMessage: true, + expanded: true, + }); + expect(screen.getByText(/Line1/)).toBeInTheDocument(); + expect(screen.getByText(/line2/)).toBeInTheDocument(); + expect(screen.getByText(/line3/)).toBeInTheDocument(); + expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 73e456887c9..841923d3f17 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -29,6 +29,7 @@ interface Props { styles: LogRowStyles; mouseIsOver: boolean; onBlur: () => void; + expanded?: boolean; } interface LogMessageProps { @@ -58,13 +59,20 @@ const LogMessage = ({ hasAnsi, entry, highlights, styles }: LogMessageProps) => return <>{entry}; }; -const restructureLog = (line: string, prettifyLogMessage: boolean): string => { +const restructureLog = ( + line: string, + prettifyLogMessage: boolean, + wrapLogMessage: boolean, + expanded: boolean +): string => { if (prettifyLogMessage) { try { return JSON.stringify(JSON.parse(line), undefined, 2); - } catch (error) { - return line; - } + } catch (error) {} + } + // With wrapping disabled, we want to turn it into a single-line log entry unless the line is expanded + if (!wrapLogMessage && !expanded) { + line = line.replace(/(\r\n|\n|\r)/g, ''); } return line; }; @@ -84,9 +92,13 @@ export const LogRowMessage = React.memo((props: Props) => { mouseIsOver, onBlur, getRowContextQuery, + expanded, } = props; const { hasAnsi, raw } = row; - const restructuredEntry = useMemo(() => restructureLog(raw, prettifyLogMessage), [raw, prettifyLogMessage]); + const restructuredEntry = useMemo( + () => restructureLog(raw, prettifyLogMessage, wrapLogMessage, Boolean(expanded)), + [raw, prettifyLogMessage, wrapLogMessage, expanded] + ); const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]); return ( <> From 4817156ba33d69d4cf721bc7dcab0682a3eaf9b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 12:18:19 +0300 Subject: [PATCH 10/12] I18n: Download translations from Crowdin (#88365) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 4 ++++ public/locales/es-ES/grafana.json | 4 ++++ public/locales/fr-FR/grafana.json | 4 ++++ public/locales/pt-BR/grafana.json | 4 ++++ public/locales/zh-Hans/grafana.json | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index eabed80a896..0077e777cca 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1179,6 +1179,10 @@ "subtitle": "Optimieren Sie die Leistung mit Erkenntnissen aus k6 und Synthetic Monitoring", "title": "Testen & Synthetics" }, + "trash": { + "subtitle": "", + "title": "" + }, "upgrading": { "title": "Statistiken und Lizenz" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index f7bf21dbb90..2a2abb57e69 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1179,6 +1179,10 @@ "subtitle": "Optimiza el rendimiento con información de seguimiento k6 y sintética", "title": "Pruebas y síntesis" }, + "trash": { + "subtitle": "", + "title": "" + }, "upgrading": { "title": "Estadísticas y licencia" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 68d57c85979..98b50d6e649 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1179,6 +1179,10 @@ "subtitle": "Optimisez les performances grâce aux analyses de la surveillance synthétique et k6", "title": "Tests et synthèses" }, + "trash": { + "subtitle": "", + "title": "" + }, "upgrading": { "title": "Statistiques et licence" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 4f7f3737262..36de83ffa63 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -1179,6 +1179,10 @@ "subtitle": "Otimize o desempenho com visões k6 e monitorização sintética", "title": "Teste e sintética" }, + "trash": { + "subtitle": "", + "title": "" + }, "upgrading": { "title": "Estatísticas e licença" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index c44c15afc17..e111b1d7d33 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1173,6 +1173,10 @@ "subtitle": "使用 k6 和 Synthetic Monitoring 洞察优化性能。", "title": "测试与合成" }, + "trash": { + "subtitle": "", + "title": "" + }, "upgrading": { "title": "统计信息和许可证" }, From cd3c10d9050c7a7aed00ed4f0269cbe31874fdc0 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 28 May 2024 11:49:02 +0200 Subject: [PATCH 11/12] Chore: Sync sass with theme (#88340) chore(sass): run theme:generate to sync sass with theme --- public/sass/_variables.light.generated.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/sass/_variables.light.generated.scss b/public/sass/_variables.light.generated.scss index 34a3fa204c5..7d4fac1eb15 100644 --- a/public/sass/_variables.light.generated.scss +++ b/public/sass/_variables.light.generated.scss @@ -101,7 +101,7 @@ $text-color: rgba(36, 41, 46, 1); $text-color-strong: #000000; $text-color-semi-weak: rgba(36, 41, 46, 0.75); $text-color-weak: rgba(36, 41, 46, 0.75); -$text-color-faint: rgba(36, 41, 46, 0.50); +$text-color-faint: rgba(36, 41, 46, 0.64); $text-color-emphasis: #000000; $text-blue: #1F62E0; @@ -114,7 +114,7 @@ $brand-gradient-vertical: linear-gradient(0.01deg, #F53E4C -31.2%, #FF8833 113.0 // Links // ------------------------- $link-color: rgba(36, 41, 46, 1); -$link-color-disabled: rgba(36, 41, 46, 0.50); +$link-color-disabled: rgba(36, 41, 46, 0.64); $link-hover-color: #000000; $external-link-color: #1F62E0; @@ -215,7 +215,7 @@ $input-border-color: rgba(36, 41, 46, 0.30); $input-box-shadow: none; $input-border-focus: #5794f2; $input-box-shadow-focus: #5794f2; -$input-color-placeholder: rgba(36, 41, 46, 0.50); +$input-color-placeholder: rgba(36, 41, 46, 0.64); $input-label-bg: #F4F5F5; $input-color-select-arrow: #7b8087; From 7e25ff5756076e69862de2bd4e7da118eeaad14f Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 28 May 2024 10:49:43 +0100 Subject: [PATCH 12/12] Select: Support option groups in virtualised `Select`s (#88232) * change select group headers to always be visible * remove unnecessary SelectOptionGroup * hacky change to work with virtualised lists * undo this change * add top border * remove children from the category header * don't virtualize story * bit of renaming * comments * add new story for virtualized options --- packages/grafana-ui/package.json | 2 + .../src/components/Select/Select.story.tsx | 41 +++++++++++++++++++ .../src/components/Select/SelectMenu.tsx | 41 ++++++++++++++++--- yarn.lock | 6 ++- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 2e510b2668e..62a634bdea1 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -132,6 +132,7 @@ "@testing-library/jest-dom": "6.4.2", "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", + "@types/chance": "1.1.6", "@types/common-tags": "^1.8.0", "@types/d3": "7.4.3", "@types/hoist-non-react-statics": "3.3.5", @@ -158,6 +159,7 @@ "@types/testing-library__jest-dom": "5.14.9", "@types/tinycolor2": "1.4.6", "@types/uuid": "9.0.8", + "chance": "1.1.11", "common-tags": "1.8.2", "core-js": "3.37.0", "css-loader": "7.1.1", diff --git a/packages/grafana-ui/src/components/Select/Select.story.tsx b/packages/grafana-ui/src/components/Select/Select.story.tsx index 6a69cc4b657..20d3d29e416 100644 --- a/packages/grafana-ui/src/components/Select/Select.story.tsx +++ b/packages/grafana-ui/src/components/Select/Select.story.tsx @@ -1,6 +1,7 @@ import { auto } from '@popperjs/core'; import { action } from '@storybook/addon-actions'; import { Meta, StoryFn } from '@storybook/react'; +import Chance from 'chance'; import React, { useState } from 'react'; import { SelectableValue, toIconName } from '@grafana/data'; @@ -12,6 +13,26 @@ import mdx from './Select.mdx'; import { generateOptions, generateThousandsOfOptions } from './mockOptions'; import { SelectCommonProps } from './types'; +const chance = new Chance(); + +const manyGroupedOptions = [ + { label: 'Foo', value: '1' }, + { + label: 'Animals', + options: new Array(100).fill(0).map((_, i) => { + const animal = chance.animal(); + return { label: animal, value: animal }; + }), + }, + { + label: 'People', + options: new Array(100).fill(0).map((_, i) => { + const person = chance.name(); + return { label: person, value: person }; + }), + }, +]; + const meta: Meta = { title: 'Forms/Select', component: Select, @@ -242,6 +263,26 @@ export const MultiSelectWithOptionGroups: StoryFn = (args) => { ); }; +export const MultiSelectWithOptionGroupsVirtualized: StoryFn = (args) => { + const [value, setValue] = useState(); + + return ( + <> + { + setValue(v.map((v) => v.value!)); + action('onChange')(v); + }} + prefix={getPrefix(args.icon)} + {...args} + /> + + ); +}; + export const MultiSelectBasic: StoryFn = (args) => { const [value, setValue] = useState>>([]); diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index 593e72866a4..e07c53d5b6e 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -1,6 +1,6 @@ import { cx } from '@emotion/css'; import { max } from 'lodash'; -import React, { RefCallback, useEffect, useRef } from 'react'; +import React, { RefCallback, useEffect, useMemo, useRef } from 'react'; import { MenuListProps } from 'react-select'; import { FixedSizeList as List } from 'react-window'; @@ -57,8 +57,18 @@ export const VirtualizedSelectMenu = ({ const styles = getSelectStyles(theme); const listRef = useRef(null); - const focusedIndex = options.findIndex((option: SelectableValue) => option.value === focusedOption?.value); + // we need to check for option groups (categories) + // these are top level options with child options + // if they exist, flatten the list of options + const flattenedOptions = useMemo( + () => options.flatMap((option) => (option.options ? [option, ...option.options] : [option])), + [options] + ); + // scroll the focused option into view when navigating with keyboard + const focusedIndex = flattenedOptions.findIndex( + (option: SelectableValue) => option.value === focusedOption?.value + ); useEffect(() => { listRef.current?.scrollToItem(focusedIndex); }, [focusedIndex]); @@ -67,10 +77,23 @@ export const VirtualizedSelectMenu = ({ return null; } - const longestOption = max(options.map((option) => option.label?.length)) ?? 0; + // flatten the children to account for any categories + // these will have array children that are the individual options + const flattenedChildren = children.flatMap((child) => { + if (hasArrayChildren(child)) { + // need to remove the children from the category else they end up in the DOM twice + const childWithoutChildren = React.cloneElement(child, { + children: null, + }); + return [childWithoutChildren, ...child.props.children]; + } + return [child]; + }); + + const longestOption = max(flattenedOptions.map((option) => option.label?.length)) ?? 0; const widthEstimate = longestOption * VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER + VIRTUAL_LIST_PADDING * 2 + VIRTUAL_LIST_WIDTH_EXTRA; - const heightEstimate = Math.min(options.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight); + const heightEstimate = Math.min(flattenedChildren.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight); return ( - {({ index, style }) =>
{children[index]}
} + {({ index, style }) =>
{flattenedChildren[index]}
}
); }; +// check if a child has array children (and is therefore a react-select group) +// we need to flatten these so the correct count and elements are passed to the virtualized list +const hasArrayChildren = (child: React.ReactNode) => { + return React.isValidElement(child) && Array.isArray(child.props.children); +}; + VirtualizedSelectMenu.displayName = 'VirtualizedSelectMenu'; interface SelectMenuOptionProps { diff --git a/yarn.lock b/yarn.lock index c53990fd397..8c14398c898 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3648,6 +3648,7 @@ __metadata: "@testing-library/jest-dom": "npm:6.4.2" "@testing-library/react": "npm:15.0.2" "@testing-library/user-event": "npm:14.5.2" + "@types/chance": "npm:1.1.6" "@types/common-tags": "npm:^1.8.0" "@types/d3": "npm:7.4.3" "@types/hoist-non-react-statics": "npm:3.3.5" @@ -3676,6 +3677,7 @@ __metadata: "@types/uuid": "npm:9.0.8" ansicolor: "npm:1.1.100" calculate-size: "npm:1.1.1" + chance: "npm:1.1.11" classnames: "npm:2.5.1" common-tags: "npm:1.8.2" core-js: "npm:3.37.0" @@ -7827,7 +7829,7 @@ __metadata: languageName: node linkType: hard -"@types/chance@npm:^1.1.3": +"@types/chance@npm:1.1.6, @types/chance@npm:^1.1.3": version: 1.1.6 resolution: "@types/chance@npm:1.1.6" checksum: 10/f4366f1b3144d143af3e6f0fad2ed1db7b9bdfa7d82d40944e9619d57fe7e6b60e8c1452f47a8ededa6b2188932879518628ecd9aac81c40384ded39c26338ba @@ -11639,7 +11641,7 @@ __metadata: languageName: node linkType: hard -"chance@npm:^1.0.10": +"chance@npm:1.1.11, chance@npm:^1.0.10": version: 1.1.11 resolution: "chance@npm:1.1.11" checksum: 10/d76cc76dbcb837f051e6080d94be8bdcd07559d52077854f614c7081062fee10d4c3b508f6ae4b70303dac000422ea35160b01de165fcd176a01f67ea4b2cef5