From f6404b9589839609d2c313de32e4495265ae2fa2 Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:18:12 +0200 Subject: [PATCH 01/53] Query Library: Connect QueryLibraryEditingHeader in QueryEditorRow (#109818) * Query Library: Connect QueryLibraryEditingHeader in QueryEditorRow * Add unit test to queryn editor row * Remove logic of "update query" save disk and add extra condition to prevent dragable action --- .../QueryLibrary/QueryLibraryContext.tsx | 24 ++- .../features/explore/QueryLibrary/mocks.tsx | 1 + .../query/components/QueryEditorRow.test.tsx | 69 +++++--- .../query/components/QueryEditorRow.tsx | 161 +++++++++--------- .../QueryLibraryEditingContainer.tsx | 25 +++ public/locales/en-US/grafana.json | 7 +- 6 files changed, 179 insertions(+), 108 deletions(-) create mode 100644 public/app/features/query/components/QueryLibraryEditingContainer.tsx diff --git a/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx index 1ac7ef7cd97..1930c56d81a 100644 --- a/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx +++ b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx @@ -40,10 +40,28 @@ export type QueryLibraryContextType = { renderSaveQueryButton: ( query: DataQuery, app?: CoreApp, - queryLibraryRef?: string, onUpdateSuccess?: () => void, onSelectQuery?: (query: DataQuery) => void ) => ReactNode; + + /** + * Returns a header component for editing queries from the library. + * used in places like Explore + * @param query + * @param app + * @param queryLibraryRef + * @param onCancelEdit + * @param onUpdateSuccess + */ + renderQueryLibraryEditingHeader: ( + query: DataQuery, + app?: CoreApp, + queryLibraryRef?: string, + onCancelEdit?: () => void, + onUpdateSuccess?: () => void, + onSelectQuery?: (query: DataQuery) => void + ) => ReactNode; + queryLibraryEnabled: boolean; context: string; triggerAnalyticsEvent: ( @@ -66,6 +84,10 @@ export const QueryLibraryContext = createContext({ return null; }, + renderQueryLibraryEditingHeader: () => { + return null; + }, + queryLibraryEnabled: false, context: 'unknown', triggerAnalyticsEvent: () => {}, diff --git a/public/app/features/explore/QueryLibrary/mocks.tsx b/public/app/features/explore/QueryLibrary/mocks.tsx index 2c25a64e0bc..ecef7d95870 100644 --- a/public/app/features/explore/QueryLibrary/mocks.tsx +++ b/public/app/features/explore/QueryLibrary/mocks.tsx @@ -14,6 +14,7 @@ export function QueryLibraryContextProviderMock(props: PropsWithChildren) closeDrawer: jest.fn(), isDrawerOpen: false, renderSaveQueryButton: jest.fn(), + renderQueryLibraryEditingHeader: jest.fn(), queryLibraryEnabled: Boolean(props.queryLibraryEnabled), context: 'explore', triggerAnalyticsEvent: jest.fn(), diff --git a/public/app/features/query/components/QueryEditorRow.test.tsx b/public/app/features/query/components/QueryEditorRow.test.tsx index 8cc33cd4529..80e3ef12919 100644 --- a/public/app/features/query/components/QueryEditorRow.test.tsx +++ b/public/app/features/query/components/QueryEditorRow.test.tsx @@ -5,7 +5,7 @@ import { DataQueryRequest, dateTime, LoadingState, PanelData, toDataFrame } from import { DataQuery } from '@grafana/schema'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; -import { filterPanelDataToQuery, Props, QueryEditorRow, QueryLibraryEditingBadge } from './QueryEditorRow'; +import { filterPanelDataToQuery, Props, QueryEditorRow } from './QueryEditorRow'; const mockDS = mockDataSource({ name: 'test', @@ -15,6 +15,12 @@ const mockDS = mockDataSource({ // Mock the QueryLibraryContext const mockQueryLibraryContext = { queryLibraryEnabled: true, + renderQueryLibraryEditingHeader: jest.fn(), + renderSaveQueryButton: jest.fn(() => null), + openDrawer: jest.fn(), + closeDrawer: jest.fn(), + isDrawerOpen: false, + context: 'test', }; jest.mock('app/features/explore/QueryLibrary/QueryLibraryContext', () => ({ @@ -404,31 +410,50 @@ describe('QueryEditorRow', () => { expect(screen.queryByText('Error!!')).not.toBeInTheDocument(); }); }); -}); -describe('QueryLibraryBadge', () => { - beforeEach(() => { - mockQueryLibraryContext.queryLibraryEnabled = true; - }); + describe('Query Library Integration', () => { + let testData: PanelData; + let mockOnCancelEdit: jest.MockedFunction<() => void>; - it('should display badge when queryLibraryEnabled is true and queryLibraryRef is provided', () => { - render(); - expect(screen.getByText('Updating query from library')).toBeInTheDocument(); - }); + beforeEach(() => { + jest.clearAllMocks(); + mockQueryLibraryContext.renderQueryLibraryEditingHeader.mockReturnValue(null); + mockOnCancelEdit = jest.fn(); - it('should not display badge when queryLibraryEnabled is false', () => { - mockQueryLibraryContext.queryLibraryEnabled = false; - render(); - expect(screen.queryByText('Updating query from library')).not.toBeInTheDocument(); - }); + // Standard test data for QueryEditorRow + testData = { + series: [], + timeRange: { from: dateTime(), to: dateTime(), raw: { from: 'now-1d', to: 'now' } }, + state: LoadingState.Done, + }; + }); - it('should not display badge when queryLibraryRef is not provided', () => { - render(); - expect(screen.queryByText('Updating query from library')).not.toBeInTheDocument(); - }); + it('should render query library editing header when queryLibraryRef is provided', async () => { + render( + + ); - it('should not display badge when queryLibraryRef is empty string', () => { - render(); - expect(screen.queryByText('Updating query from library')).not.toBeInTheDocument(); + // Wait for async datasource loading and component rendering + await waitFor(() => { + expect(mockQueryLibraryContext.renderQueryLibraryEditingHeader).toHaveBeenCalledWith( + expect.objectContaining({ refId: 'B' }), + undefined, // app + 'test-ref', // queryLibraryRef + mockOnCancelEdit, // onCancelEdit + expect.any(Function), // onUpdateSuccess + expect.any(Function) // onSelectQuery + ); + }); + }); + + it('should not render query library editing header when queryLibraryRef is not provided', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('query-editor-row')).toBeInTheDocument(); + }); + + expect(mockQueryLibraryContext.renderQueryLibraryEditingHeader).not.toHaveBeenCalled(); + }); }); }); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index a8bda63f862..d6e58fd8038 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -22,7 +22,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { getDataSourceSrv, renderLimitedComponents, reportInteraction, usePluginComponents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { Badge, Divider, ErrorBoundaryAlert, List } from '@grafana/ui'; +import { Badge, ErrorBoundaryAlert, List } from '@grafana/ui'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; import { QueryOperationAction, @@ -38,6 +38,7 @@ import { useQueryLibraryContext } from '../../explore/QueryLibrary/QueryLibraryC import { QueryActionComponent, RowActionComponents } from './QueryActionComponent'; import { QueryEditorRowHeader } from './QueryEditorRowHeader'; import { QueryErrorAlert } from './QueryErrorAlert'; +import { QueryLibraryEditingContainer } from './QueryLibraryEditingContainer'; export interface Props { data: PanelData; @@ -346,11 +347,6 @@ export class QueryEditorRow extends PureComponent { - const { queryLibraryRef } = this.props; - return ; - }; - renderExtraActions = () => { const { query, queries, data, onAddQuery, dataSource, app } = this.props; @@ -392,14 +388,14 @@ export class QueryEditorRow extends PureComponent - {isEditingQueryLibrary && this.renderQueryLibraryEditingBadge()} - + {!isEditingQueryLibrary && ( + + )} {!isEditingQueryLibrary && ( extends PureComponent )} - {isEditingQueryLibrary && ( - <> - - - - )} - {hasEditorHelp && ( extends PureComponent extends PureComponent +
+ + {showingHelp && DatasourceCheatsheet && ( + + this.onClickExample(query)} + query={this.props.query} + datasource={datasource} + /> + + )} + {editor} + + {error && } + {visualization} +
+ + ); + return (
- -
- - {showingHelp && DatasourceCheatsheet && ( - - this.onClickExample(query)} - query={this.props.query} - datasource={datasource} - /> - - )} - {editor} - - {error && } - {visualization} -
-
+ {queryLibraryRef && ( + + )} + {queryLibraryRef ? ( + {queryOperationRow} + ) : ( + queryOperationRow + )}
); } } -export function QueryLibraryEditingBadge(props: { queryLibraryRef?: string }) { - const { queryLibraryEnabled } = useQueryLibraryContext(); - const { queryLibraryRef } = props; - - if (!queryLibraryEnabled || !queryLibraryRef) { - return null; - } - - return ( - - ); -} - /** * Get a version of the PanelData limited to the query we are looking at */ @@ -601,15 +591,28 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat function MaybeQueryLibrarySaveButton(props: { query: DataQuery; app?: CoreApp; - queryLibraryRef?: string; onUpdateSuccess?: () => void; onSelectQuery: (query: DataQuery) => void; }) { const { renderSaveQueryButton } = useQueryLibraryContext(); - return renderSaveQueryButton( + return renderSaveQueryButton(props.query, props.app, props.onUpdateSuccess, props.onSelectQuery); +} + +// Will render editing header only if query library is enabled +function MaybeQueryLibraryEditingHeader(props: { + query: DataQuery; + app?: CoreApp; + queryLibraryRef?: string; + onCancelEdit?: () => void; + onUpdateSuccess?: () => void; + onSelectQuery?: (query: DataQuery) => void; +}) { + const { renderQueryLibraryEditingHeader } = useQueryLibraryContext(); + return renderQueryLibraryEditingHeader( props.query, props.app, props.queryLibraryRef, + props.onCancelEdit, props.onUpdateSuccess, props.onSelectQuery ); diff --git a/public/app/features/query/components/QueryLibraryEditingContainer.tsx b/public/app/features/query/components/QueryLibraryEditingContainer.tsx new file mode 100644 index 00000000000..97e1c794c7f --- /dev/null +++ b/public/app/features/query/components/QueryLibraryEditingContainer.tsx @@ -0,0 +1,25 @@ +import { css } from '@emotion/css'; +import { ReactNode } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +interface QueryLibraryEditingContainerProps { + children: ReactNode; +} + +export function QueryLibraryEditingContainer({ children }: QueryLibraryEditingContainerProps) { + const styles = useStyles2(getStyles); + return
{children}
; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + border: `2px solid ${theme.colors.primary.main}`, + borderTopLeftRadius: 'unset', + borderTopRightRadius: 'unset', + borderBottomLeftRadius: theme.shape.radius.default, + borderBottomRightRadius: theme.shape.radius.default, + overflow: 'hidden', + }), +}); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 7423a3f2437..e8ad31317dc 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11886,7 +11886,6 @@ }, "query-operation": { "header": { - "cancel-query-library-edit": "Discard changes", "collapse-row": "Collapse query row", "datasource-help": "Show data source help", "drag-and-drop": "Drag and drop to reorder", @@ -11897,11 +11896,7 @@ "replace-query-from-library": "Replace with query from library", "show-response": "Show response" }, - "query-editor-not-exported": "Data source plugin does not export any Query Editor component", - "query-library": { - "editing-tooltip": "Updating query from library\nUID: {{queryLibraryRef}}", - "from-library": "Updating query from library" - } + "query-editor-not-exported": "Data source plugin does not export any Query Editor component" }, "recently-deleted": { "buttons": { From 485831f0b2cf4953c5e7f87d8529eb6b754aa11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 20 Aug 2025 10:21:18 +0200 Subject: [PATCH 02/53] Dashboards: Repeating with no clone keys (#109839) * Dashboards: New pathId concept to find panels not based on scene object key * Minor fix * fix test * Fix test * adding unit tests for pathId functions * fix * Fix link sharing to use new path * fix * Update * No clone keys * Remove all the clone keys complexity * More changes * update e2e test * Progress * fix auto grid item * Finally working cannot move panel into clone row * adjust how we find vizpanel for keyboard shortcuts * Update * Fix shortcuts * Fix test * fixing tests * fix lint * fix tests * fix e2e * adjust how outliine children are collected for auto and custom grids * update * Upgrade scenes --------- Co-authored-by: oscarkilhed Co-authored-by: Sergej-Vlasov --- .../various-suite/solo-route.spec.ts | 6 +- e2e/old-arch/various-suite/solo-route.spec.ts | 6 +- e2e/various-suite/solo-route.spec.ts | 6 +- package.json | 4 +- public/app/core/utils/shortLinks.ts | 2 +- .../edit-pane/DashboardEditPane.tsx | 23 +--- .../edit-pane/DashboardOutline.tsx | 4 +- .../scene/DashboardDatasourceBehaviour.tsx | 4 +- .../scene/DashboardScene.test.tsx | 9 +- .../dashboard-scene/scene/DashboardScene.tsx | 6 +- .../scene/DashboardSceneUrlSync.test.ts | 19 ++- .../scene/DashboardSceneUrlSync.ts | 10 +- .../scene/PanelMenuBehavior.test.tsx | 2 +- .../scene/PanelMenuBehavior.tsx | 21 +-- .../dashboard-scene/scene/ViewPanelScene.tsx | 2 +- .../scene/keyboardShortcuts.ts | 13 +- .../scene/layout-auto-grid/AutoGridItem.tsx | 5 +- .../scene/layout-auto-grid/AutoGridLayout.tsx | 5 + .../AutoGridLayoutManager.test.ts | 43 +++--- .../AutoGridLayoutManager.tsx | 32 ++--- .../AutoGridLayoutRenderer.tsx | 5 +- .../layout-default/DashboardGridItem.test.tsx | 5 +- .../layout-default/DashboardGridItem.tsx | 5 +- .../DefaultGridLayoutManager.test.tsx | 16 --- .../DefaultGridLayoutManager.tsx | 80 +---------- .../RowRepeaterBehavior.test.tsx | 50 +------ .../layout-default/RowRepeaterBehavior.ts | 77 ++--------- .../scene/layout-rows/RowItem.tsx | 2 + .../scene/layout-rows/RowItemRenderer.tsx | 4 +- .../layout-rows/RowItemRepeater.test.tsx | 2 +- .../scene/layout-rows/RowItemRepeater.tsx | 9 +- .../scene/layout-rows/RowsLayoutManager.tsx | 45 +++--- .../layout-rows/RowsLayoutManagerRenderer.tsx | 4 +- .../scene/layout-tabs/TabItem.tsx | 2 + .../scene/layout-tabs/TabItemRenderer.tsx | 4 +- .../layout-tabs/TabItemRepeater.test.tsx | 2 +- .../scene/layout-tabs/TabItemRepeater.tsx | 9 +- .../scene/layout-tabs/TabsLayoutManager.tsx | 36 ++--- .../layout-tabs/TabsLayoutManagerRenderer.tsx | 4 +- .../layoutSerializers/RowsLayoutSerializer.ts | 3 +- .../layoutSerializers/TabsLayoutSerializer.ts | 3 +- .../transformSceneToSaveModel.ts | 4 +- .../sharing/ShareLinkTab.test.tsx | 19 ++- .../dashboard-scene/sharing/ShareLinkTab.tsx | 4 +- .../sharing/SharePanelEmbedTab.tsx | 2 +- .../solo/useSoloPanel.test.tsx | 2 +- .../dashboard-scene/solo/useSoloPanel.ts | 17 ++- .../dashboard-scene/utils/clone.test.ts | 130 +----------------- .../features/dashboard-scene/utils/clone.ts | 96 ++----------- .../utils/dashboardSceneGraph.ts | 10 +- .../dashboard-scene/utils/pathId.test.ts | 98 +++++++++++++ .../features/dashboard-scene/utils/pathId.ts | 32 +++++ .../dashboard-scene/utils/urlBuilders.ts | 2 +- .../features/dashboard-scene/utils/utils.ts | 92 +------------ .../datasource/dashboard/datasource.ts | 9 +- yarn.lock | 22 +-- 56 files changed, 384 insertions(+), 744 deletions(-) create mode 100644 public/app/features/dashboard-scene/utils/pathId.test.ts create mode 100644 public/app/features/dashboard-scene/utils/pathId.ts diff --git a/e2e-playwright/various-suite/solo-route.spec.ts b/e2e-playwright/various-suite/solo-route.spec.ts index 63dd4e6439d..7c09bce8da3 100644 --- a/e2e-playwright/various-suite/solo-route.spec.ts +++ b/e2e-playwright/various-suite/solo-route.spec.ts @@ -35,12 +35,12 @@ test.describe( test('Can view solo repeated panel in scenes', async ({ page, selectors }) => { // open Panel Tests - Graph NG const soloPanelUrl = selectors.pages.SoloPanel.url( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=A$panel-2&__feature.dashboardSceneSolo=true' ); await page.goto(soloPanelUrl); // Check that the panel title exists - const panelTitle = page.getByTestId(selectors.components.Panels.Panel.title('server=B')); + const panelTitle = page.getByTestId(selectors.components.Panels.Panel.title('server=A')); await expect(panelTitle).toBeVisible(); // Check that uplot-main-div does not exist @@ -51,7 +51,7 @@ test.describe( test('Can view solo in repeated row and panel in scenes', async ({ page, selectors }) => { // open Panel Tests - Graph NG const soloPanelUrl = selectors.pages.SoloPanel.url( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=B$2$panel-2&__feature.dashboardSceneSolo=true' ); await page.goto(soloPanelUrl); diff --git a/e2e/old-arch/various-suite/solo-route.spec.ts b/e2e/old-arch/various-suite/solo-route.spec.ts index 2abd2717d5b..df026f66033 100644 --- a/e2e/old-arch/various-suite/solo-route.spec.ts +++ b/e2e/old-arch/various-suite/solo-route.spec.ts @@ -25,17 +25,17 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=A$panel-2&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=B$2$panel-2&__feature.dashboardSceneSolo=true' ); e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist'); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index 2abd2717d5b..df026f66033 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -25,17 +25,17 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=A$panel-2&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=B$2$panel-2&__feature.dashboardSceneSolo=true' ); e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist'); diff --git a/package.json b/package.json index 9babb485971..ede30bbfe43 100644 --- a/package.json +++ b/package.json @@ -290,8 +290,8 @@ "@grafana/plugin-ui": "0.10.9", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "6.29.7", - "@grafana/scenes-react": "6.29.7", + "@grafana/scenes": "^6.30.0", + "@grafana/scenes-react": "^6.30.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index b6bfc95b006..53ebf12af8b 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -99,7 +99,7 @@ export const getShareUrlParams = ( const urlParamsUpdate: UrlQueryMap = {}; if (panel) { - urlParamsUpdate.viewPanel = panel.state.key; + urlParamsUpdate.viewPanel = panel.getPathId(); } if (opts.useAbsoluteTimeRange) { diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index bdcee40b3dd..70dee7c9c67 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -6,8 +6,8 @@ import { } from '@grafana/ui'; import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; -import { containsCloneKey, getLastKeyFromClone, isInCloneChain } from '../utils/clone'; -import { findEditPanel, getDashboardSceneFor } from '../utils/utils'; +import { isRepeatCloneOrChildOf } from '../utils/clone'; +import { getDashboardSceneFor } from '../utils/utils'; import { ElementSelection } from './ElementSelection'; import { @@ -183,24 +183,13 @@ export class DashboardEditPane extends SceneObjectBase { } private selectElement(element: ElementSelectionContextItem, options: ElementSelectionOnSelectOptions) { - // We should not select clones - if (isInCloneChain(element.id)) { - if (options.multi) { + let obj = sceneGraph.findByKey(this, element.id); + if (obj) { + // Do not select repeat clones or their children + if (isRepeatCloneOrChildOf(obj)) { return; } - this.clearSelection(); - return; - } - - let obj = sceneGraph.findByKey(this, element.id); - if (obj) { - if (obj instanceof VizPanel && containsCloneKey(getLastKeyFromClone(element.id))) { - const sourceVizPanel = findEditPanel(this, element.id); - if (sourceVizPanel) { - obj = sourceVizPanel; - } - } this.selectObject(obj, element.id, options); } } diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index bda1336db94..218a8709dee 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n'; import { SceneObject } from '@grafana/scenes'; import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui'; -import { isInCloneChain } from '../utils/clone'; +import { isRepeatCloneOrChildOf } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; import { DashboardEditPane } from './DashboardEditPane'; @@ -39,7 +39,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth }: DashboardOutline const { key } = sceneObject.useState(); const [isCollapsed, setIsCollapsed] = useState(depth > 0); const { isSelected, onSelect } = useElementSelection(key); - const isCloned = useMemo(() => isInCloneChain(key!), [key]); + const isCloned = useMemo(() => isRepeatCloneOrChildOf(sceneObject), [sceneObject]); const editableElement = useMemo(() => getEditableElementFor(sceneObject)!, [sceneObject]); const noTitleText = t('dashboard.outline.tree-item.no-title', ''); diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx index a35663eeaac..69fd3472c0b 100644 --- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.tsx @@ -5,7 +5,7 @@ import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constan import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { - findOriginalVizPanelByKey, + findVizPanelByKey, getDashboardSceneFor, getLibraryPanelBehavior, getQueryRunnerFor, @@ -54,7 +54,7 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase { it('Should hash the key of the cloned panels and set it as panelId', () => { const queryRunner = sceneGraph.findObject(scene, (o) => o.state.key === 'data-query-runner2')!; - const expectedPanelId = djb2Hash(getCloneKey('panel-2', 1)); - expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(expectedPanelId); + expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(3670868617); }); }); @@ -974,6 +973,10 @@ function buildTestScene(overrides?: Partial) { body: new VizPanel({ title: 'Panel B', key: getCloneKey('panel-2', 1), + repeatSourceKey: 'panel-2', + $variables: new SceneVariableSet({ + variables: [new LocalValueVariable({ name: 'a', value: 'A' })], + }), pluginId: 'table', $data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }), }), diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 0dc7501cbc1..4d85840b1d4 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -61,7 +61,7 @@ import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history/HistorySrv'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; -import { isInCloneChain } from '../utils/clone'; +import { isRepeatCloneOrChildOf } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/getDashboardUrl'; @@ -660,9 +660,9 @@ export class DashboardScene extends SceneObjectBase impleme let panelId = 0; if (panel && panel.state.key) { - if (isInCloneChain(panel.state.key)) { + if (isRepeatCloneOrChildOf(panel)) { // We check if any of the panel ancestors are clones because we can't use the original panel ID in this case - panelId = djb2Hash(panel?.state.key); + panelId = djb2Hash(panel.getPathId()); } else { // Otherwise, it's the absolute original panel, and we can use the key directly // getPanelIdForVizPanel extracts the panel ID from the key so we don't need to do it manually diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index 4bad8552455..d89a729ca17 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -1,10 +1,8 @@ import { AppEvents } from '@grafana/data'; -import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { LocalValueVariable, SceneQueryRunner, SceneVariableSet, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types/dashboard'; -import { getCloneKey } from '../utils/clone'; - import { DashboardScene } from './DashboardScene'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; @@ -72,7 +70,7 @@ describe('DashboardSceneUrlSync', () => { let errorNotice = 0; appEvents.on(AppEvents.alertError, (evt) => errorNotice++); - scene.urlSync?.updateFromUrl({ viewPanel: getCloneKey('panel-1', 1) }); + scene.urlSync?.updateFromUrl({ viewPanel: 'A$panel-1' }); expect(scene.state.viewPanelScene).toBeUndefined(); // Verify no error notice was shown @@ -87,8 +85,17 @@ describe('DashboardSceneUrlSync', () => { key: 'griditem-1', x: 0, body: new VizPanel({ + $variables: new SceneVariableSet({ + variables: [ + new LocalValueVariable({ + name: 'server', + value: 'A', + text: 'A', + }), + ], + }), title: 'Clone Panel A', - key: getCloneKey('panel-1', 1), + key: 'panel-1', pluginId: 'table', }), }), @@ -97,7 +104,7 @@ describe('DashboardSceneUrlSync', () => { // Verify it subscribes to DashboardRepeatsProcessedEvent scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene })); - expect(scene.state.viewPanelScene?.getUrlKey()).toBe(getCloneKey('panel-1', 1)); + expect(scene.state.viewPanelScene?.getUrlKey()).toBe('A$panel-1'); }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index b145635bb39..fcc52e46b81 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -11,8 +11,8 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { containsCloneKey } from '../utils/clone'; -import { findEditPanel, findVizPanelByKey, getLibraryPanelBehavior } from '../utils/utils'; +import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId'; +import { findEditPanel, getLibraryPanelBehavior } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; @@ -74,13 +74,13 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { // Handle view panel state if (typeof values.viewPanel === 'string') { - const panel = findVizPanelByKey(this._scene, values.viewPanel); + const panel = findVizPanelByPathId(this._scene, values.viewPanel); if (!panel) { // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet // Here we check if the key contains the clone key so we force the repeat processing // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key - if (containsCloneKey(values.viewPanel)) { + if (containsPathIdSeparator(values.viewPanel)) { this._handleViewRepeatClone(values.viewPanel); return; } @@ -162,7 +162,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { private _handleViewRepeatClone(viewPanel: string) { if (!this._viewEventSub) { this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { - const panel = findVizPanelByKey(this._scene, viewPanel); + const panel = findVizPanelByPathId(this._scene, viewPanel); if (panel) { this._viewEventSub?.unsubscribe(); this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) }); diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 8d841b7d1dc..02490e3d5c7 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -76,7 +76,7 @@ describe('panelMenuBehavior', () => { expect(menu.state.items?.length).toBe(6); // verify view panel url keeps url params and adds viewPanel= - expect(menu.state.items?.[0].href).toBe('/d/dash-1?from=now-5m&to=now&viewPanel=panel-12'); + expect(menu.state.items?.[0].href).toBe('/d/dash-1?from=now-5m&to=now&viewPanel=a$panel-12'); // verify edit url keeps url time range expect(menu.state.items?.[1].href).toBe('/d/dash-1?from=now-5m&to=now&editPanel=12'); // verify share diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index d02e94516dd..30774d910fb 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -12,7 +12,7 @@ import { } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, locationService } from '@grafana/runtime'; -import { LocalValueVariable, sceneGraph, SceneGridRow, VizPanel, VizPanelMenu } from '@grafana/scenes'; +import { LocalValueVariable, sceneGraph, VizPanel, VizPanelMenu } from '@grafana/scenes'; import { DataQuery, OptionsWithLegend } from '@grafana/schema'; import appEvents from 'app/core/app_events'; import { createErrorNotification } from 'app/core/copy/appNotification'; @@ -35,7 +35,7 @@ import { ShowConfirmModalEvent } from 'app/types/events'; import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { isInCloneChain } from '../utils/clone'; +import { isRepeatCloneOrChildOf } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; @@ -74,7 +74,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { const dashboard = getDashboardSceneFor(panel); const { isEmbedded } = dashboard.state.meta; const exploreMenuItem = await getExploreMenuItem(panel); - const isReadOnlyRepeat = isInCloneChain(panel.state.key!); + const isReadOnlyRepeat = isRepeatCloneOrChildOf(panel); // For embedded dashboards we only have explore action for now if (isEmbedded) { @@ -504,21 +504,6 @@ function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): Plu }); } - // Handle row repeats scenario - if (panel.parent?.parent instanceof SceneGridRow) { - const row = panel.parent.parent; - if (row.state.$variables) { - row.state.$variables.state.variables.forEach((variable) => { - if (variable instanceof LocalValueVariable) { - scopedVars = { - ...scopedVars, - [variable.state.name]: { value: variable.getValue(), text: variable.getValueText() }, - }; - } - }); - } - } - return { id, pluginId: panel.state.pluginId, diff --git a/public/app/features/dashboard-scene/scene/ViewPanelScene.tsx b/public/app/features/dashboard-scene/scene/ViewPanelScene.tsx index 46486395aef..fcd47bb7034 100644 --- a/public/app/features/dashboard-scene/scene/ViewPanelScene.tsx +++ b/public/app/features/dashboard-scene/scene/ViewPanelScene.tsx @@ -19,7 +19,7 @@ export class ViewPanelScene extends SceneObjectBase { } public getUrlKey() { - return this.state.panelRef.resolve().state.key; + return this.state.panelRef.resolve().getPathId(); } public static Component = ({ model }: SceneComponentProps) => { diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts index a4f2ef2e2fd..bf3872b2603 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts @@ -12,6 +12,7 @@ import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; +import { findVizPanelByPathId } from '../utils/pathId'; import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; import { getPanelIdForVizPanel } from '../utils/utils'; @@ -21,20 +22,24 @@ import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutMana export function setupKeyboardShortcuts(scene: DashboardScene) { const keybindings = new KeybindingSet(); - let vizPanelKey: string | null = null; + let vizPanelPathId: string | null = null; const canEdit = scene.canEditDashboard(); const panelAttentionSubscription = appEvents.subscribe(SetPanelAttentionEvent, (event) => { if (typeof event.payload.panelId === 'string') { - vizPanelKey = event.payload.panelId; + vizPanelPathId = event.payload.panelId; } }); function withFocusedPanel(scene: DashboardScene, fn: (vizPanel: VizPanel) => void) { return () => { - const vizPanel = sceneGraph.findObject(scene, (o) => o.state.key === vizPanelKey); - if (vizPanel && vizPanel instanceof VizPanel) { + if (vizPanelPathId == null) { + return; + } + + const vizPanel = findVizPanelByPathId(scene, vizPanelPathId); + if (vizPanel) { fn(vizPanel); return; } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index 99bcff261e6..6f6d211777b 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -116,7 +116,10 @@ export class AutoGridItem extends SceneObjectBase implements const isSource = index === 0; const clone = isSource ? panelToRepeat - : panelToRepeat.clone({ key: getCloneKey(panelToRepeat.state.key!, index) }); + : panelToRepeat.clone({ + key: getCloneKey(panelToRepeat.state.key!, index), + repeatSourceKey: panelToRepeat.state.key, + }); clone.setState({ $variables: getLocalVariableValueSet(variable, variableValues[index], variableTexts[index]) }); diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx index b96550cfa22..7ba318f2f4b 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx @@ -2,6 +2,7 @@ import { createRef, CSSProperties, PointerEvent as ReactPointerEvent } from 'rea import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { getLayoutOrchestratorFor } from '../../utils/utils'; import { AutoGridItem } from './AutoGridItem'; @@ -92,6 +93,10 @@ export class AutoGridLayout extends SceneObjectBase impleme } public isDraggable(): boolean { + if (isRepeatCloneOrChildOf(this)) { + return false; + } + return this.state.isDraggable ?? false; } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.test.ts b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.test.ts index 76dbfdbf291..cb4499c30bb 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.test.ts +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.test.ts @@ -1,6 +1,6 @@ import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; -import { findVizPanelByKey } from '../../utils/utils'; +import { DashboardEditActionEvent } from '../../edit-pane/shared'; import { DashboardScene } from '../DashboardScene'; import { AutoGridItem } from './AutoGridItem'; @@ -8,33 +8,38 @@ import { AutoGridLayout } from './AutoGridLayout'; import { AutoGridLayoutManager } from './AutoGridLayoutManager'; describe('AutoGridLayoutManager', () => { - it('Should clone the layout', () => { - const { manager } = setup(); - const clone = manager.cloneLayout('foo', true) as AutoGridLayoutManager; + it('can remove panel', () => { + const { manager, panel1 } = setup(); - expect(clone).not.toBe(manager); - expect(clone.state.layout).not.toBe(manager.state.layout); - expect(clone.state.layout.state.children).not.toBe(manager.state.layout.state.children); - expect(clone.state.layout.state.children.length).toBe(manager.state.layout.state.children.length); + manager.subscribeToEvent(DashboardEditActionEvent, (event) => { + event.payload.perform(); + }); - const panelA = findVizPanelByKey(clone, 'foo/grid-item-1/panel-1'); - expect(panelA?.state.title).toBe('Panel A'); + manager.removePanel(panel1); - const panelB = findVizPanelByKey(clone, 'foo/grid-item-2/panel-2'); - expect(panelB?.state.title).toBe('Panel B'); + expect(manager.state.layout.state.children.length).toBe(1); }); }); function setup() { + const panel1 = new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }); + + const panel2 = new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }); + const gridItems = [ new AutoGridItem({ key: 'grid-item-1', - body: new VizPanel({ - title: 'Panel A', - key: 'panel-1', - pluginId: 'table', - $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), - }), + body: panel1, }), new AutoGridItem({ key: 'grid-item-2', @@ -50,5 +55,5 @@ function setup() { new DashboardScene({ body: manager }); - return { manager }; + return { manager, panel1, panel2 }; } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx index 69ee13494ac..397c0ce6bb8 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx @@ -7,13 +7,11 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { dashboardEditActions, NewObjectAddedToCanvasEvent } from '../../edit-pane/shared'; import { serializeAutoGridLayout } from '../../serialization/layoutSerializers/AutoGridLayoutSerializer'; -import { joinCloneKeys } from '../../utils/clone'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; import { forceRenderChildren, getDashboardSceneFor, getGridItemKeyForPanelId, - getPanelIdForVizPanel, getVizPanelKeyForPanelId, } from '../../utils/utils'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; @@ -90,8 +88,13 @@ export class AutoGridLayoutManager } public getOutlineChildren(): SceneObject[] { - const outlineChildren = this.state.layout.state.children.map((gridItem) => gridItem.state.body); - return outlineChildren; + const children: SceneObject[] = []; + + for (const child of this.state.layout.state.children) { + children.push(child.state.body, ...(child.state.repeatedPanels || [])); + } + + return children; } public addPanel(vizPanel: VizPanel) { @@ -245,26 +248,7 @@ export class AutoGridLayoutManager } public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { - return this.clone({ - layout: this.state.layout.clone({ - isDraggable: isSource && this.state.layout.state.isDraggable, - children: this.state.layout.state.children.map((gridItem) => { - if (gridItem instanceof AutoGridItem) { - // Get the original panel ID from the gridItem's key - const panelId = getPanelIdForVizPanel(gridItem.state.body); - const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(panelId)); - - return gridItem.clone({ - key: gridItemKey, - body: gridItem.state.body.clone({ - key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(panelId)), - }), - }); - } - throw new Error('Unexpected child type'); - }), - }), - }); + return this.clone({}); } public getOptions(): OptionsPaneItemDescriptor[] { diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx index c70a3ef5200..8208df99d9d 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx @@ -4,7 +4,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; -import { useHasClonedParents } from '../../utils/clone'; +import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { useDashboardState } from '../../utils/utils'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; @@ -14,7 +14,6 @@ import { AutoGridLayoutManager } from './AutoGridLayoutManager'; export function AutoGridLayoutRenderer({ model }: SceneComponentProps) { const { children, isHidden } = model.useState(); - const hasClonedParents = useHasClonedParents(model); const styles = useStyles2(getStyles, model.state); const { layoutOrchestrator, isEditing } = useDashboardState(model); const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager); @@ -24,7 +23,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps { expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A'); expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2'); - expect(panel1.state.key).toBe('panel-1'); - expect(isInCloneChain(panel2.state.key!)).toBe(true); + expect(panel1.state.repeatSourceKey).toBe(undefined); + expect(panel2.state.repeatSourceKey).toBe(repeater.state.body.state.key); }); it('Should wait for variable to load', async () => { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index f228575170b..bea25d00605 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -173,7 +173,10 @@ export class DashboardGridItem const isSource = index === 0; const clone = isSource ? panelToRepeat - : panelToRepeat.clone({ key: getCloneKey(panelToRepeat.state.key!, index) }); + : panelToRepeat.clone({ + key: getCloneKey(panelToRepeat.state.key!, index), + repeatSourceKey: panelToRepeat.state.key, + }); clone.setState({ $variables: getLocalVariableValueSet(variable, variableValues[index], variableTexts[index]) }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx index c2e3bb77691..fdb9dd9fc38 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx @@ -210,22 +210,6 @@ describe('DefaultGridLayoutManager', () => { expect(gridRow.state.children.length).toBe(3); }); - - it('Should clone the layout correctly', () => { - const { manager } = setup(); - const clone = manager.cloneLayout('foo', true) as DefaultGridLayoutManager; - const panelA = findVizPanelByKey(clone, 'foo/grid-item-0/panel-0'); - expect(panelA?.state.title).toBe('Panel A'); - - const panelB = findVizPanelByKey(clone, 'foo/grid-item-1/panel-1'); - expect(panelB?.state.title).toBe('Panel B'); - - const panelC = findVizPanelByKey(clone, 'foo/panel-2/grid-item-3/panel-3'); - expect(panelC?.state.title).toBe('Panel C'); - - const panelD = findVizPanelByKey(clone, 'foo/panel-2/grid-item-4/panel-4'); - expect(panelD?.state.title).toBe('Panel D'); - }); }); }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 76fffddd0a5..97c04cbdd86 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -29,7 +29,7 @@ import { ObjectsReorderedOnCanvasEvent, } from '../../edit-pane/shared'; import { serializeDefaultGridLayout } from '../../serialization/layoutSerializers/DefaultGridLayoutSerializer'; -import { isClonedKey, joinCloneKeys, useHasClonedParents } from '../../utils/clone'; +import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; import { forceRenderChildren, @@ -433,11 +433,7 @@ export class DefaultGridLayoutManager for (const child of this.state.grid.state.children) { // Flatten repeated grid items if (child instanceof DashboardGridItem) { - if (child.state.repeatedPanels) { - children.push(...child.state.repeatedPanels); - } else { - children.push(child.state.body); - } + children.push(child.state.body, ...(child.state.repeatedPanels || [])); } } @@ -445,75 +441,7 @@ export class DefaultGridLayoutManager } public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { - return this.clone({ - grid: this.state.grid.clone({ - isResizable: isSource && this.state.grid.state.isResizable, - isDraggable: isSource && this.state.grid.state.isDraggable, - children: this.state.grid.state.children.reduce<{ panelId: number; children: SceneGridItemLike[] }>( - (childrenAcc, child) => { - if (child instanceof DashboardGridItem) { - const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(childrenAcc.panelId)); - - const gridItem = child.clone({ - key: gridItemKey, - body: child.state.body.clone({ - key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), - }), - isDraggable: isSource && child.state.isDraggable, - isResizable: isSource && child.state.isResizable, - }); - - childrenAcc.children.push(gridItem); - - return childrenAcc; - } - - if (child instanceof SceneGridRow) { - const rowKey = joinCloneKeys(ancestorKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)); - - const row = child.clone({ - key: rowKey, - children: child.state.children.reduce((rowAcc, rowChild) => { - if (isClonedKey(rowChild.state.key!)) { - return rowAcc; - } - - if (!(rowChild instanceof DashboardGridItem)) { - rowAcc.push(rowChild.clone()); - return rowAcc; - } - - const gridItemKey = joinCloneKeys(rowKey, getGridItemKeyForPanelId(childrenAcc.panelId)); - - const gridItem = rowChild.clone({ - key: gridItemKey, - isDraggable: isSource && rowChild.state.isDraggable, - isResizable: isSource && rowChild.state.isResizable, - body: rowChild.state.body.clone({ - key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), - }), - }); - - rowAcc.push(gridItem); - return rowAcc; - }, []), - isDraggable: isSource && child.state.isDraggable, - isResizable: isSource && child.state.isResizable, - }); - - childrenAcc.children.push(row); - - return childrenAcc; - } - - childrenAcc.children.push(child.clone()); - - return childrenAcc; - }, - { panelId: 0, children: [] } - ).children, - }), - }); + return this.clone({}); } public removeRow(row: SceneGridRow, removePanels = false) { @@ -634,7 +562,7 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps { // Verify that first row still has repeat behavior const row1 = grid.state.children[1] as SceneGridRow; - expect(row1.state.key).toBe(getCloneKey('row-1', 0)); + expect(row1.state.key).toBe('row-1'); expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior); expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1'); expect(row1.state.actions).toBeDefined(); const gridItemRow1 = row1.state.children[0] as SceneGridItem; - expect(gridItemRow1.state.key!).toBe(joinCloneKeys(row1.state.key!, 'grid-item-1')); - expect(gridItemRow1.state.body?.state.key).toBe('canvas-1'); + expect(gridItemRow1.state.key!).toBe('grid-item-1'); const row2 = grid.state.children[2] as SceneGridRow; expect(row2.state.key).toBe(getCloneKey('row-1', 1)); @@ -70,51 +69,14 @@ describe('RowRepeaterBehavior', () => { expect(row2.state.actions).toBeUndefined(); const gridItemRow2 = row2.state.children[0] as SceneGridItem; - expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-1')); - expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1')); + expect(gridItemRow2.state.key!).toBe(row2.state.key! + 'grid-item-1'); }); it('Repeated rows should be read only', () => { const row1 = grid.state.children[1] as SceneGridRow; const row2 = grid.state.children[2] as SceneGridRow; - expect(isInCloneChain(row1.state.key!)).toBe(false); - expect(isInCloneChain(row2.state.key!)).toBe(true); - }); - - it('Should update all rows when a panel is added to a clone', async () => { - const originalRow = grid.state.children[1] as SceneGridRow; - const clone1 = grid.state.children[2] as SceneGridRow; - const clone2 = grid.state.children[3] as SceneGridRow; - - expect(originalRow.state.children.length).toBe(1); - expect(clone1.state.children.length).toBe(1); - expect(clone2.state.children.length).toBe(1); - - clone1.setState({ - children: [ - ...clone1.state.children, - new SceneGridItem({ - x: 0, - y: 16, - width: 24, - height: 5, - key: 'grid-item-4', - body: new SceneCanvasText({ - text: 'new panel', - }), - }), - ], - }); - - grid.forceRender(); - - // repeater has run so there are new clone row objects - const newClone1 = grid.state.children[2] as SceneGridRow; - const newClone2 = grid.state.children[3] as SceneGridRow; - - expect(originalRow.state.children.length).toBe(2); - expect(newClone1.state.children.length).toBe(2); - expect(newClone2.state.children.length).toBe(2); + expect(isRepeatCloneOrChildOf(row1)).toBe(false); + expect(isRepeatCloneOrChildOf(row2)).toBe(true); }); it('Should push row at the bottom down', () => { diff --git a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts index a9cd9969c94..a813fddaace 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts @@ -12,16 +12,7 @@ import { VariableValueSingle, } from '@grafana/scenes'; -import { - containsCloneKey, - getLastKeyFromClone, - isClonedKeyOf, - joinCloneKeys, - getCloneKey, - isClonedKey, - getOriginalKey, - getLocalVariableValueSet, -} from '../../utils/clone'; +import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; @@ -49,11 +40,10 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); const sub = layout.subscribeToState(() => { - const repeatedRows = layout.state.children.filter((child) => - isClonedKeyOf(child.state.key!, originalRow.state.key!) + const repeatedRows = layout.state.children.filter( + (child) => child instanceof SceneGridRow && child.state.repeatSourceKey === originalRow.state.key ); // go through cloned rows, search for panels that are not clones @@ -62,31 +52,12 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); - // if no differences in row children compared to original, then no new panel added to clone - if (rowNonClonedPanels.length === originalRowNonClonedPanels.length) { + if (row.state.children.length === originalRow.state.children.length) { continue; } - // if there are differences, find the new panel, move it to the original and perform repeat - const gridItem = rowNonClonedPanels.find((gridItem) => !containsCloneKey(gridItem.state.key!)); - - if (gridItem) { - const newGridItem = gridItem.clone(); - - row.setState({ children: row.state.children.filter((item) => item !== gridItem) }); - - // if we are moving a panel from the origin row to a clone row, we just return - // this means we are modifying the origin row, re-triggering the repeat and losing that panel - if (originalRow.state.children.find((item) => item.state.key === newGridItem.state.key)) { - return; - } - - originalRow.setState({ children: [...originalRow.state.children, newGridItem] }); - - this.performRepeat(true); - } + this.performRepeat(true); } }); @@ -163,15 +134,14 @@ export class RowRepeaterBehavior extends SceneObjectBase 0 ? sourceItem.clone() : sourceItem; const cloneItemY = sourceItemY + (rowContentHeight + 1) * rowIndex; - const cloneItem = - rowIndex > 0 - ? sourceItem.clone({ - isDraggable: false, - isResizable: false, - }) - : sourceItem; - - cloneItem.setState({ - key: cloneItemKey, - y: cloneItemY, - }); + // Update grid item keys on clone rows (not needed on source row) + // Needed to not have duplicate grid items keys in the same grid if (rowIndex > 0) { - ensureUniqueKeys(cloneItem, cloneItemKey); + cloneItem.setState({ y: cloneItemY, key: rowClone.state.key + sourceItem.state.key! }); } children.push(cloneItem); @@ -252,9 +211,7 @@ function getRowContentHeight(panels: SceneGridItemLike[]): number { function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowKey: string) { const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowKey); - const index = allChildren.findIndex( - (child) => child instanceof SceneGridRow && getOriginalKey(child.state.key!) === getOriginalKey(rowKey) - ); + const index = allChildren.findIndex((child) => child instanceof SceneGridRow && child.state.key === rowKey); if (index === -1) { throw new Error('RowRepeaterBehavior: Parent row not found in layout children'); @@ -284,14 +241,6 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowKey: string) { return layout.state.children.filter( - (child) => !(child instanceof SceneGridRow) || !isClonedKeyOf(getLastKeyFromClone(child.state.key!), rowKey) + (child) => !(child instanceof SceneGridRow) || child.state.repeatSourceKey !== rowKey ); } - -function ensureUniqueKeys(item: SceneGridItemLike, ancestors: string) { - item.forEachChild((child) => { - const key = joinCloneKeys(ancestors, child.state.key!); - child.setState({ key }); - ensureUniqueKeys(child, key); - }); -} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index 7bbc2ffebe3..72e34058ab8 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -45,6 +45,8 @@ export interface RowItemState extends SceneObjectState { conditionalRendering?: ConditionalRendering; repeatByVariable?: string; repeatedRows?: RowItem[]; + /** Marks object as a repeated object and a key pointer to source object */ + repeatSourceKey?: string; } export class RowItem diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index affa68d54ea..1913ce0c2b4 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -9,7 +9,7 @@ import { SceneComponentProps } from '@grafana/scenes'; import { clearButtonStyles, Icon, Tooltip, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui'; import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden'; -import { useIsClone } from '../../utils/clone'; +import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { useDashboardState, useInterpolatedTitle } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; @@ -17,7 +17,7 @@ import { RowItem } from './RowItem'; export function RowItemRenderer({ model }: SceneComponentProps) { const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState(); - const isClone = useIsClone(model); + const isClone = isRepeatCloneOrChildOf(model); const { isEditing } = useDashboardState(model); const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(model); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx index c6ac4f4066b..c4c7496cda4 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.test.tsx @@ -38,7 +38,7 @@ describe('RowItemRepeater', () => { expect(screen.queryByText('Row C')).toBeInTheDocument(); }); - expect(rowToRepeat.state.key).toBe('row-1-clone-0'); + expect(rowToRepeat.state.key).toBe('row-1'); expect(rowToRepeat.state.repeatedRows!.length).toBe(2); expect(rowToRepeat.state.repeatedRows![0].state.key).toBe('row-1-clone-1'); }); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx index ea606dcaadf..39681c4fa4c 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx @@ -97,12 +97,17 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co const rowCloneKey = getCloneKey(row.state.key!, rowIndex); const rowClone = isSourceRow ? row - : row.clone({ repeatByVariable: undefined, repeatedRows: undefined, layout: undefined }); + : row.clone({ + key: rowCloneKey, + repeatSourceKey: row.state.key, + repeatByVariable: undefined, + repeatedRows: undefined, + layout: undefined, + }); const layout = isSourceRow ? row.getLayout() : row.getLayout().cloneLayout(rowCloneKey, false); rowClone.setState({ - key: rowCloneKey, $variables: getLocalVariableValueSet(variable, variableValues[rowIndex], variableTexts[rowIndex]), layout, }); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 1cdf72a480c..d021eac7889 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -12,7 +12,6 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared'; import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer'; -import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { getDashboardSceneFor } from '../../utils/utils'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; @@ -67,16 +66,7 @@ export class RowsLayoutManager extends SceneObjectBase i } public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { - return this.clone({ - rows: this.state.rows.map((row) => { - const key = joinCloneKeys(ancestorKey, row.state.key!); - - return row.clone({ - key, - layout: row.state.layout.cloneLayout(key, isSource), - }); - }), - }); + return this.clone({}); } public duplicate(): DashboardLayoutManager { @@ -188,7 +178,7 @@ export class RowsLayoutManager extends SceneObjectBase i if (layout instanceof TabsLayoutManager) { for (const tab of layout.state.tabs) { - if (isClonedKey(tab.state.key!)) { + if (tab.state.repeatSourceKey) { continue; } @@ -221,21 +211,24 @@ export class RowsLayoutManager extends SceneObjectBase i } if (child instanceof SceneGridRow) { - if (!isClonedKey(child.state.key!)) { - const behaviour = child.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior); - - config.push({ - title: child.state.title, - isCollapsed: !!child.state.isCollapsed, - isDraggable: child.state.isDraggable, - isResizable: child.state.isResizable, - children: child.state.children, - repeat: behaviour?.state.variableName, - }); - - // Since we encountered a row item, any subsequent panels should be added to a new row - children = undefined; + // Skip repeated row clones + if (child.state.repeatSourceKey) { + return; } + + const behaviour = child.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior); + + config.push({ + title: child.state.title, + isCollapsed: !!child.state.isCollapsed, + isDraggable: child.state.isDraggable, + isResizable: child.state.isResizable, + children: child.state.children, + repeat: behaviour?.state.variableName, + }); + + // Since we encountered a row item, any subsequent panels should be added to a new row + children = undefined; } else { if (!children) { children = []; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx index 9c74a5b37a7..523b44c6422 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -7,7 +7,7 @@ import { Trans } from '@grafana/i18n'; import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectState } from '@grafana/scenes'; import { Button, useStyles2 } from '@grafana/ui'; -import { isInCloneChain } from '../../utils/clone'; +import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { useDashboardState } from '../../utils/utils'; import { useClipboardState } from '../layouts-shared/useClipboardState'; @@ -21,7 +21,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); const pointerDistance = usePointerDistance(); const [isConditionallyHidden] = useIsConditionallyHidden(model); - const isClone = useIsClone(model); + const isClone = isRepeatCloneOrChildOf(model); const isDraggable = !isClone && isEditing; diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.test.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.test.tsx index 6533dab789d..d8a7cf80988 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.test.tsx @@ -38,7 +38,7 @@ describe('TabItemRepeater', () => { expect(screen.queryByText('Tab C')).toBeInTheDocument(); }); - expect(tabToRepeat.state.key).toBe('tab-1-clone-0'); + expect(tabToRepeat.state.key).toBe('tab-1'); expect(tabToRepeat.state.repeatedTabs!.length).toBe(2); expect(tabToRepeat.state.repeatedTabs![0].state.key).toBe('tab-1-clone-1'); }); diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx index e8ce39b892c..16ac618b600 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx @@ -152,12 +152,17 @@ export function createTabRepeats({ const tabCloneKey = getCloneKey(tab.state.key!, tabIndex); const tabClone = isSourceTab ? tab - : tab.clone({ repeatByVariable: undefined, repeatedTabs: undefined, layout: undefined }); + : tab.clone({ + key: tabCloneKey, + repeatSourceKey: tab.state.key, + repeatByVariable: undefined, + repeatedTabs: undefined, + layout: undefined, + }); const layout = isSourceTab ? tab.getLayout() : tab.getLayout().cloneLayout(tabCloneKey, false); tabClone.setState({ - key: tabCloneKey, $variables: getLocalVariableValueSet(variable, variableValues[tabIndex], variableTexts[tabIndex]), layout, }); diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 4ebf8583330..ec14a3c2d3a 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -12,15 +12,6 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared'; import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer'; -import { - containsCloneKey, - getCloneKey, - getLastKeyFromClone, - getOriginalKey, - isClonedKey, - isClonedKeyOf, - joinCloneKeys, -} from '../../utils/clone'; import { getDashboardSceneFor } from '../../utils/utils'; import { RowItem } from '../layout-rows/RowItem'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; @@ -138,16 +129,7 @@ export class TabsLayoutManager extends SceneObjectBase i } public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { - return this.clone({ - tabs: this.state.tabs.map((tab) => { - const key = joinCloneKeys(ancestorKey, tab.state.key!); - - return tab.clone({ - key, - layout: tab.state.layout.cloneLayout(key, isSource), - }); - }), - }); + return this.clone(); } public getOutlineChildren() { @@ -257,19 +239,19 @@ export class TabsLayoutManager extends SceneObjectBase i let destinationTab = allTabs[toIndex]; let selectionIndex = toIndex; - if (containsCloneKey(getLastKeyFromClone(destinationTab.state.key!))) { - if (isClonedKeyOf(destinationTab.state.key!, objectToMove.state.key!)) { + if (destinationTab.state.repeatSourceKey) { + if (destinationTab.state.repeatSourceKey === objectToMove.state.repeatSourceKey) { // moving tab between its clones return; } - const originalTabKey = getCloneKey(getOriginalKey(destinationTab.state.key!), 0); - const originalTabIndex = allTabs.findIndex((tab) => tab.state.key === originalTabKey); - if (originalTabIndex !== -1) { - destinationTab = allTabs[originalTabIndex]; + const sourceTabIndx = allTabs.findIndex((tab) => tab.state.key === destinationTab.state.repeatSourceKey); + + if (sourceTabIndx !== -1) { + destinationTab = allTabs[sourceTabIndx]; const isMovingLeft = toIndex < fromIndex; - selectionIndex = originalTabIndex + (isMovingLeft ? 0 : destinationTab.state.repeatedTabs?.length || 0); + selectionIndex = sourceTabIndx + (isMovingLeft ? 0 : destinationTab.state.repeatedTabs?.length || 0); } } @@ -319,7 +301,7 @@ export class TabsLayoutManager extends SceneObjectBase i if (layout instanceof RowsLayoutManager) { for (const row of layout.state.rows) { - if (isClonedKey(row.state.key!)) { + if (row.state.repeatSourceKey) { continue; } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx index 1e4c81c546a..a223350371b 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx @@ -8,7 +8,7 @@ import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectStat import { Button, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden'; -import { isInCloneChain } from '../../utils/clone'; +import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { getDashboardSceneFor } from '../../utils/utils'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; import { useClipboardState } from '../layouts-shared/useClipboardState'; @@ -27,7 +27,7 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts index 284d0a4fec2..81e901c0a32 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -2,7 +2,6 @@ import { Spec as DashboardV2Spec, RowsLayoutRowKind } from '@grafana/schema/dist import { RowItem } from '../../scene/layout-rows/RowItem'; import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; -import { isClonedKey } from '../../utils/clone'; import { layoutDeserializerRegistry } from './layoutSerializerRegistry'; import { getConditionalRendering } from './utils'; @@ -11,7 +10,7 @@ export function serializeRowsLayout(layoutManager: RowsLayoutManager): Dashboard return { kind: 'RowsLayout', spec: { - rows: layoutManager.state.rows.filter((row) => !isClonedKey(row.state.key!)).map(serializeRow), + rows: layoutManager.state.rows.filter((row) => !row.state.repeatSourceKey).map(serializeRow), }, }; } diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts index 883f80c5415..52d378d98c3 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts @@ -2,7 +2,6 @@ import { Spec as DashboardV2Spec, TabsLayoutTabKind } from '@grafana/schema/dist import { TabItem } from '../../scene/layout-tabs/TabItem'; import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager'; -import { isClonedKey } from '../../utils/clone'; import { layoutDeserializerRegistry } from './layoutSerializerRegistry'; import { getConditionalRendering } from './utils'; @@ -11,7 +10,7 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard return { kind: 'TabsLayout', spec: { - tabs: layoutManager.state.tabs.filter((tab) => !isClonedKey(tab.state.key!)).map(serializeTab), + tabs: layoutManager.state.tabs.filter((tab) => !tab.state.repeatSourceKey).map(serializeTab), }, }; } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index f5b9a700bd8..a18a5918c7e 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -36,7 +36,6 @@ import { PanelTimeRange } from '../scene/PanelTimeRange'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; -import { isClonedKey } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { @@ -74,9 +73,10 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa if (child instanceof SceneGridRow) { // Skip repeat clones or when generating a snapshot - if (isClonedKey(child.state.key!) && !isSnapshot) { + if (child.state.repeatSourceKey && !isSnapshot) { continue; } + gridRowToSaveModel(child, panels, isSnapshot); } } diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx index c7bb7284d0f..74b7952ab02 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx @@ -6,7 +6,7 @@ import { dateTime } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; import { selectors } from '@grafana/e2e-selectors'; import { config, locationService, setPluginImportUtils } from '@grafana/runtime'; -import { SceneTimeRange, VizPanel } from '@grafana/scenes'; +import { LocalValueVariable, SceneTimeRange, SceneVariableSet, VizPanel } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; @@ -46,7 +46,7 @@ describe('ShareLinkTab', () => { buildAndRenderScenario({}); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12' + 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12' ); }); }); @@ -57,7 +57,7 @@ describe('ShareLinkTab', () => { await act(() => tab.onToggleLockedTime()); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/d/dash-1?from=now-6h&to=now&viewPanel=panel-12' + 'http://dashboards.grafana.com/grafana/d/dash-1?from=now-6h&to=now&viewPanel=A$panel-12' ); }); }); @@ -67,7 +67,7 @@ describe('ShareLinkTab', () => { await act(() => tab.onThemeChange('light')); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=panel-12&theme=light' + 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12&theme=light' ); }); @@ -88,7 +88,7 @@ describe('ShareLinkTab', () => { await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute( 'href', - 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster' + 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster' ); }); }); @@ -102,6 +102,15 @@ function buildAndRenderScenario(options: ScenarioOptions) { title: 'Panel A', pluginId: 'table', key: 'panel-12', + $variables: new SceneVariableSet({ + variables: [ + new LocalValueVariable({ + name: 'server', + value: 'A', + text: 'A', + }), + ], + }), }); const tab = new ShareLinkTab({ panelRef: panel.getRef() }); const scene = new DashboardScene({ diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 0800e9fe0ad..9d2daa5812b 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -76,7 +76,7 @@ export class ShareLinkTab extends SceneObjectBase implements let imageQueryParams = urlParamsUpdate; if (panel) { delete imageQueryParams.viewPanel; - imageQueryParams.panelId = panel.state.key; + imageQueryParams.panelId = panel.getPathId(); // force solo route to use scenes imageQueryParams['__feature.dashboardSceneSolo'] = true; } @@ -84,7 +84,7 @@ export class ShareLinkTab extends SceneObjectBase implements const imageUrl = getDashboardUrl({ uid: dashboard.state.uid, currentQueryParams: window.location.search, - updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.state.key }, + updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.getPathId() }, absolute: true, soloRoute: true, render: true, diff --git a/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx b/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx index e63cda018a1..5d37b375dbe 100644 --- a/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx +++ b/public/app/features/dashboard-scene/sharing/SharePanelEmbedTab.tsx @@ -43,7 +43,7 @@ function SharePanelEmbedTabRenderer({ model }: SceneComponentProps { it('should return the cloned panel when panel is found', () => { const { dashboard } = setup(); - const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1-clone-1')); + const { result } = renderHook(() => useSoloPanel(dashboard, 'A$panel-1')); const panel = findVizPanelByKey(dashboard, 'panel-1'); expect(result.current[0]).not.toBe(panel); diff --git a/public/app/features/dashboard-scene/solo/useSoloPanel.ts b/public/app/features/dashboard-scene/solo/useSoloPanel.ts index acc7409bdc3..37777439b4c 100644 --- a/public/app/features/dashboard-scene/solo/useSoloPanel.ts +++ b/public/app/features/dashboard-scene/solo/useSoloPanel.ts @@ -4,10 +4,9 @@ import { VizPanel, UrlSyncManager } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; import { DashboardRepeatsProcessedEvent } from '../scene/types/DashboardRepeatsProcessedEvent'; -import { containsCloneKey } from '../utils/clone'; -import { findVizPanelByKey } from '../utils/utils'; +import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId'; -export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPanel | undefined, string | undefined] { +export function useSoloPanel(dashboard: DashboardScene, pathId: string): [VizPanel | undefined, string | undefined] { const [panel, setPanel] = useState(); const [error, setError] = useState(); @@ -19,7 +18,7 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa let panel: VizPanel | null = null; try { - panel = findVizPanelByKey(dashboard, panelId); + panel = findVizPanelByPathId(dashboard, pathId); } catch (e) { // do nothing, just the panel is not found or not a VizPanel } @@ -27,8 +26,8 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa if (panel) { activateParents(panel); setPanel(panel); - } else if (containsCloneKey(panelId)) { - findRepeatClone(dashboard, panelId).then((panel) => { + } else if (containsPathIdSeparator(pathId)) { + findRepeatClone(dashboard, pathId).then((panel) => { if (panel) { setPanel(panel); } else { @@ -40,7 +39,7 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa } return cleanUp; - }, [dashboard, panelId]); + }, [dashboard, pathId]); return [panel, error]; } @@ -54,10 +53,10 @@ function activateParents(panel: VizPanel) { } } -function findRepeatClone(dashboard: DashboardScene, panelId: string): Promise { +function findRepeatClone(dashboard: DashboardScene, pathId: string): Promise { return new Promise((resolve) => { dashboard.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { - const panel = findVizPanelByKey(dashboard, panelId); + const panel = findVizPanelByPathId(dashboard, pathId); if (panel) { resolve(panel); } else { diff --git a/public/app/features/dashboard-scene/utils/clone.test.ts b/public/app/features/dashboard-scene/utils/clone.test.ts index 97b0e377e6a..6772c72e9a2 100644 --- a/public/app/features/dashboard-scene/utils/clone.test.ts +++ b/public/app/features/dashboard-scene/utils/clone.test.ts @@ -1,134 +1,10 @@ -import { - getCloneKey, - getOriginalKey, - isInCloneChain, - isClonedKey, - joinCloneKeys, - containsCloneKey, - getLastKeyFromClone, - isClonedKeyOf, -} from './clone'; +import { getCloneKey } from './clone'; describe('clone', () => { describe('getCloneKey', () => { it('should return the clone key', () => { - expect(getCloneKey('panel', 1)).toBe('panel-clone-1'); - expect(getCloneKey('panel-clone-2', 1)).toBe('panel-clone-1'); - }); - - it('should not alter ancestors', () => { - expect(getCloneKey('row-clone-1/panel', 2)).toBe('row-clone-1/panel-clone-2'); - expect(getCloneKey('tab-clone-0/row-clone-1/panel', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2'); - expect(getCloneKey('row-clone-1/panel-clone-3', 2)).toBe('row-clone-1/panel-clone-2'); - expect(getCloneKey('tab-clone-0/row-clone-1/panel-clone-3', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2'); - }); - }); - - describe('getOriginalKey', () => { - it('should return the original key', () => { - expect(getOriginalKey('panel')).toBe('panel'); - expect(getOriginalKey('panel-clone-1')).toBe('panel'); - expect(getOriginalKey('row-clone-1/panel-clone-2')).toBe('panel'); - expect(getOriginalKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe('panel'); - expect(getOriginalKey('panel-2-clone-3')).toBe('panel-2'); - expect(getOriginalKey('panel-2')).toBe('panel-2'); - }); - }); - - describe('isClonedKey', () => { - it('should return true for cloned keys', () => { - expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true); - expect(isClonedKey('row-clone-0/panel-clone-1')).toBe(true); - expect(isClonedKey('panel-clone-1')).toBe(true); - }); - - it('should return false for non-cloned keys', () => { - expect(isClonedKey('panel-clone-0')).toBe(false); - expect(isClonedKey('tab-clone-1/row-clone-2/panel-clone-0')).toBe(false); - expect(isClonedKey('row-clone-1/panel-clone-0')).toBe(false); - expect(isClonedKey('panel')).toBe(false); - expect(isClonedKey('tab-clone-1/row-clone-2/panel')).toBe(false); - expect(isClonedKey('row-clone-1/panel')).toBe(false); - }); - - it('should properly handle indexes containing 0', () => { - expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-0')).toBe(false); - expect(isClonedKey('row-clone-0/panel-clone-0')).toBe(false); - expect(isClonedKey('panel-clone-0')).toBe(false); - - expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-101')).toBe(true); - expect(isClonedKey('row-clone-0/panel-clone-101')).toBe(true); - expect(isClonedKey('panel-clone-1010')).toBe(true); - - expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-10')).toBe(true); - expect(isClonedKey('row-clone-0/panel-clone-100')).toBe(true); - expect(isClonedKey('panel-clone-1000')).toBe(true); - }); - }); - - describe('isClonedKeyOf', () => { - it('should return true for cloned keys', () => { - expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel-clone-2')).toBe(true); - expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel')).toBe(true); - expect(isClonedKeyOf('panel-clone-2', 'panel-clone-2')).toBe(true); - expect(isClonedKeyOf('panel-clone-2', 'panel')).toBe(true); - }); - - it('should return false for non-cloned keys', () => { - expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2-clone-2')).toBe(false); - expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2')).toBe(false); - expect(isClonedKeyOf('panel-clone-2', 'panel2-clone-2')).toBe(false); - expect(isClonedKeyOf('panel-clone-2', 'panel2')).toBe(false); - }); - }); - - describe('isInCloneChain', () => { - it('should return true for keys with cloned ancestors', () => { - expect(isInCloneChain('tab-clone-1/row-clone-0/panel-clone-0')).toBe(true); - expect(isInCloneChain('row-clone-0/row-clone-1/panel-clone-0')).toBe(true); - expect(isInCloneChain('row-clone-0/row-clone-0/panel-clone-1')).toBe(true); - expect(isInCloneChain('panel-clone-1')).toBe(true); - }); - - it('should return false for keys without cloned ancestors', () => { - expect(isInCloneChain('panel-clone-0')).toBe(false); - expect(isInCloneChain('row-clone-0/panel-clone-0')).toBe(false); - expect(isInCloneChain('tab-clone-0/row-clone-0/panel-clone-0')).toBe(false); - expect(isInCloneChain('panel')).toBe(false); - expect(isInCloneChain('tab-clone-0/row-clone-0/panel')).toBe(false); - expect(isInCloneChain('tab-clone-0/row/panel')).toBe(false); - expect(isInCloneChain('tab-clone-0/row/panel-0')).toBe(false); - expect(isInCloneChain('tab/row-clone-0/panel-0')).toBe(false); - expect(isInCloneChain('row-clone-0/panel')).toBe(false); - }); - }); - - describe('getLastKeyFromClone', () => { - it('should return the last key', () => { - expect(getLastKeyFromClone('tab-clone-1/row-clone-2/panel-clone-3')).toBe('panel-clone-3'); - expect(getLastKeyFromClone('row-clone-1/panel-clone-2')).toBe('panel-clone-2'); - expect(getLastKeyFromClone('row-clone-1/panel')).toBe('panel'); - expect(getLastKeyFromClone('panel')).toBe('panel'); - }); - }); - - describe('joinCloneKeys', () => { - it('should join keys with a separator', () => { - expect(joinCloneKeys('row', 'panel-clone-1')).toBe('row/panel-clone-1'); - }); - }); - - describe('containsCloneKey', () => { - it('should return true for keys with clone key', () => { - expect(containsCloneKey('row-clone-0/panel-clone-1')).toBe(true); - expect(containsCloneKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true); - expect(containsCloneKey('panel-clone-1')).toBe(true); - }); - - it('should return false for keys without clone key', () => { - expect(containsCloneKey('panel')).toBe(false); - expect(containsCloneKey('tab-0/row-1/panel-2')).toBe(false); - expect(containsCloneKey('row-1/panel-2')).toBe(false); + expect(getCloneKey('panel-1', 1)).toBe('panel-1-clone-1'); + expect(getCloneKey('panel-22', 1)).toBe('panel-22-clone-1'); }); }); }); diff --git a/public/app/features/dashboard-scene/utils/clone.ts b/public/app/features/dashboard-scene/utils/clone.ts index 2dc9fac27c3..9923357bf5d 100644 --- a/public/app/features/dashboard-scene/utils/clone.ts +++ b/public/app/features/dashboard-scene/utils/clone.ts @@ -7,13 +7,7 @@ import { VariableValueSingle, } from '@grafana/scenes'; -import { DashboardScene } from '../scene/DashboardScene'; - const CLONE_KEY = '-clone-'; -const CLONE_SEPARATOR = '/'; - -const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9][0-9]*$`); -const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`); /** * Create or alter the last key for a key @@ -21,91 +15,21 @@ const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`); * @param index */ export function getCloneKey(key: string, index: number): string { - const parts = key.split(CLONE_SEPARATOR).slice(0, -1); - const lastKey = getOriginalKey(getLastKeyFromClone(key)); - return [...parts, `${lastKey}${CLONE_KEY}${index}`].join(CLONE_SEPARATOR); + return `${key}${CLONE_KEY}${index}`; } -/** - * Get the original key from a clone key - * @param key - */ -export function getOriginalKey(key: string): string { - return getLastKeyFromClone(key).replace(ORIGINAL_REGEX, ''); -} +export function isRepeatCloneOrChildOf(scene: SceneObject): boolean { + let obj: SceneObject | undefined = scene; -/** - * Checks if the last key is a clone key - * @param key - */ -export function isClonedKey(key: string): boolean { - return CLONED_KEY_REGEX.test(getLastKeyFromClone(key)); -} + do { + if ('repeatSourceKey' in obj.state && obj.state.repeatSourceKey) { + return true; + } -/** - * Checks if key1 is a clone of key2 - * @param key1 - * @param key2 - */ -export function isClonedKeyOf(key1: string, key2: string): boolean { - return isClonedKey(key1) && getOriginalKey(key1) === getOriginalKey(key2); -} + obj = obj.parent; + } while (obj); -/** - * Checks if the key or any of its ancestors are cloned - * @param key - */ -export function isInCloneChain(key: string): boolean { - return key.split(CLONE_SEPARATOR).some(isClonedKey); -} - -/** - * Get the last key from a clone key - * @param key - */ -export function getLastKeyFromClone(key: string): string { - return key.split(CLONE_SEPARATOR).pop() ?? ''; -} - -/** - * Join clone keys - * @param keys - */ -export function joinCloneKeys(...keys: string[]): string { - return keys.filter(Boolean).join(CLONE_SEPARATOR); -} - -/** - * Checks if a key contains the '-clone-' string - * @param key - */ -export function containsCloneKey(key: string): boolean { - return key.includes(CLONE_KEY); -} - -/** - * Useful hook for checking of a scene is a clone - * @param scene - */ -export function useIsClone(scene: SceneObject): boolean { - const { key } = scene.useState(); - return isClonedKey(key!); -} - -/** - * Useful hook for checking if a scene is in a clone chain - * @param scene - */ -export function useHasClonedParents(scene: SceneObject): boolean { - if (isClonedKey(scene.state.key!)) { - return true; - } - - if (!scene.parent || scene.parent instanceof DashboardScene) { - return false; - } - - return useHasClonedParents(scene.parent); + return false; } export function getLocalVariableValueSet( diff --git a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts index bd838432be5..39b2f5b5c66 100644 --- a/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts +++ b/public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts @@ -4,7 +4,6 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardScene } from '../scene/DashboardScene'; import { VizPanelLinks } from '../scene/PanelLinks'; -import { isClonedKey } from './clone'; import { getDashboardSceneFor, getLayoutManagerFor, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from './utils'; function getTimePicker(scene: DashboardScene) { @@ -37,12 +36,11 @@ export function getNextPanelId(scene: SceneObject): number { let max = 0; sceneGraph - .findAllObjects(scene.getRoot(), (obj) => obj instanceof VizPanel || obj instanceof SceneGridRow) + .findAllObjects( + scene.getRoot(), + (obj) => (obj instanceof VizPanel || obj instanceof SceneGridRow) && !obj.state.repeatSourceKey + ) .forEach((panel) => { - if (isClonedKey(panel.state.key!)) { - return; - } - const panelId = getPanelIdForVizPanel(panel); if (panelId > max) { max = panelId; diff --git a/public/app/features/dashboard-scene/utils/pathId.test.ts b/public/app/features/dashboard-scene/utils/pathId.test.ts new file mode 100644 index 00000000000..5b65accf85e --- /dev/null +++ b/public/app/features/dashboard-scene/utils/pathId.test.ts @@ -0,0 +1,98 @@ +import { + LocalValueVariable, + SceneGridItem, + SceneGridLayout, + SceneGridRow, + SceneVariableSet, + VizPanel, +} from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; + +import { findVizPanelByPathId } from './pathId'; + +describe('findVizPanelByPathId', () => { + it('should find correct panel', () => { + const { scene, panel1, repeatedPanel } = buildTestScene(); + + expect(findVizPanelByPathId(scene, 'panel-1')).toBe(panel1); + expect(findVizPanelByPathId(scene, 'US$pod1$panel-2')).toBe(repeatedPanel); + }); + + it('should find correct pane with legacy number only', () => { + const { scene, panel1 } = buildTestScene(); + + expect(findVizPanelByPathId(scene, '1')).toBe(panel1); + // This should not find anything + expect(findVizPanelByPathId(scene, '1$panel-1')).toBe(null); + }); + + it('should include local and parent local variable value', () => { + const { repeatedPanel } = buildTestScene(); + + expect(repeatedPanel.getPathId()).toBe('US$pod1$panel-2'); + }); +}); + +function buildTestScene() { + const panel1 = new VizPanel({ + title: 'Panel 1', + pluginId: 'table', + key: 'panel-1', + }); + + const repeatedPanel = new VizPanel({ + title: 'Panel 2', + pluginId: 'table', + key: 'panel-2', + $variables: new SceneVariableSet({ + variables: [new LocalValueVariable({ name: 'pod', value: 'pod1', text: 'pod1' })], + }), + }); + + const grid = new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'grid-item-1', + x: 0, + y: 0, + width: 24, + height: 10, + body: panel1, + }), + new SceneGridRow({ + key: 'row-1', + x: 0, + y: 10, + width: 24, + height: 1, + $variables: new SceneVariableSet({ + variables: [new LocalValueVariable({ name: 'datacenter', value: 'US', text: 'US' })], + }), + children: [ + new SceneGridItem({ + key: 'grid-item-2', + x: 0, + y: 11, + width: 24, + height: 5, + body: repeatedPanel, + }), + ], + }), + ], + }); + + const scene = new DashboardScene({ + title: 'My dashboard', + uid: 'dash-1', + tags: ['database', 'panel'], + meta: { + canEdit: true, + }, + body: new DefaultGridLayoutManager({ grid }), + }); + + return { scene, panel1, repeatedPanel }; +} diff --git a/public/app/features/dashboard-scene/utils/pathId.ts b/public/app/features/dashboard-scene/utils/pathId.ts new file mode 100644 index 00000000000..a77ec004cfd --- /dev/null +++ b/public/app/features/dashboard-scene/utils/pathId.ts @@ -0,0 +1,32 @@ +import { SceneObject, VizPanel, sceneGraph, PATH_ID_SEPARATOR } from '@grafana/scenes'; + +import { getVizPanelKeyForPanelId } from './utils'; + +export function findVizPanelByPathId(scene: SceneObject, pathId: string): VizPanel | null { + // Check if pathId is just an old legacy panel id + if (/^\d+$/.test(pathId)) { + pathId = getVizPanelKeyForPanelId(parseInt(pathId, 10)); + } + + const panel = sceneGraph.findObject(scene, (obj) => { + if (!(obj instanceof VizPanel)) { + return false; + } + + return pathId === obj.getPathId(); + }); + + if (panel) { + if (panel instanceof VizPanel) { + return panel; + } else { + throw new Error(`Found panel with key ${pathId} but it was not a VizPanel`); + } + } + + return null; +} + +export function containsPathIdSeparator(key: string): boolean { + return key.includes(PATH_ID_SEPARATOR); +} diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index f79f2e5fa7c..0658b0ff178 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -8,7 +8,7 @@ import { getQueryRunnerFor } from './utils'; export function getViewPanelUrl(vizPanel: VizPanel) { return locationUtil.getUrlForPartial(locationService.getLocation(), { - viewPanel: vizPanel.state.key, + viewPanel: vizPanel.getPathId(), editPanel: undefined, }); } diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 9db459df2ce..76cd899adf6 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -29,8 +29,6 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; -import { containsCloneKey, getLastKeyFromClone, getOriginalKey, isInCloneChain } from './clone'; - export const NEW_PANEL_HEIGHT = 8; export const NEW_PANEL_WIDTH = 12; @@ -44,7 +42,7 @@ export function getVizPanelKeyForPanelId(panelId: number) { } export function getPanelIdForVizPanel(panel: SceneObject): number { - return parseInt(getOriginalKey(panel.state.key!).replace('panel-', ''), 10); + return parseInt(panel.state.key!.replace('panel-', ''), 10); } /** @@ -98,102 +96,16 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP return null; } - -export function findOriginalVizPanelByKey(scene: SceneObject, key: string | undefined): VizPanel | null { - if (!key) { - return null; - } - - let panel: VizPanel | null = findOriginalVizPanelInternal(scene, key); - - if (panel) { - return panel; - } - - // Also try to find by panel id - const id = parseInt(key, 10); - if (isNaN(id)) { - return null; - } - - const panelId = getVizPanelKeyForPanelId(id); - panel = findVizPanelInternal(scene, panelId); - - if (panel) { - return panel; - } - - panel = findOriginalVizPanelInternal(scene, panelId); - - return panel; -} - -function findOriginalVizPanelInternal(scene: SceneObject, key: string | undefined): VizPanel | null { - if (!key) { - return null; - } - - const panel = sceneGraph.findObject(scene, (obj) => { - const objKey = obj.state.key!; - - // Compare the original keys - if (objKey === key || (!isInCloneChain(objKey) && getOriginalKey(objKey) === getOriginalKey(key))) { - return true; - } - - if (!(obj instanceof VizPanel)) { - return false; - } - - return false; - }); - - if (panel) { - if (panel instanceof VizPanel) { - return panel; - } else { - throw new Error(`Found panel with key ${key} but it was not a VizPanel`); - } - } - - return null; -} - export function findEditPanel(scene: SceneObject, key: string | undefined): VizPanel | null { if (!key) { return null; } - // First we try to find the non-cloned panel - // This means it is either in not in a repeat chain or every item in the chain is not a clone - let panel: SceneObject | null = findOriginalVizPanelByKey(scene, key); + let panel: SceneObject | null = findVizPanelByKey(scene, key); if (!panel || !panel.state.key) { return null; } - // Get the actual panel key, without any of the ancestors - const panelKey = getLastKeyFromClone(panel.state.key); - - // If the panel contains clone in the key, this means it's a repeated panel, and we need to find the original panel - if (containsCloneKey(panelKey)) { - // Get the original key of the panel that we are looking for - const originalPanelKey = getOriginalKey(panelKey); - // Start the search from the parent to avoid unnecessary checks - // The parent usually is the grid item where the referenced panel is also located - panel = sceneGraph.findObject(panel.parent ?? scene, (sceneObject) => { - if (!sceneObject.state.key || isInCloneChain(sceneObject.state.key)) { - return false; - } - - const currentLastKey = getLastKeyFromClone(sceneObject.state.key); - if (containsCloneKey(currentLastKey)) { - return false; - } - - return getOriginalKey(currentLastKey) === originalPanelKey; - }); - } - if (!(panel instanceof VizPanel)) { return null; } diff --git a/public/app/plugins/datasource/dashboard/datasource.ts b/public/app/plugins/datasource/dashboard/datasource.ts index 524b044fed0..e525a2bb69c 100644 --- a/public/app/plugins/datasource/dashboard/datasource.ts +++ b/public/app/plugins/datasource/dashboard/datasource.ts @@ -24,7 +24,7 @@ import { config } from '@grafana/runtime'; import { SceneDataProvider, SceneDataTransformer, SceneObject } from '@grafana/scenes'; import { activateSceneObjectAndParentTree, - findOriginalVizPanelByKey, + findVizPanelByKey, getVizPanelKeyForPanelId, } from 'app/features/dashboard-scene/utils/utils'; @@ -63,7 +63,7 @@ export class DashboardDatasource extends DataSourceApi { return of({ data: [] }); } - let sourcePanel = this.findSourcePanel(scene, panelId); + let sourcePanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(panelId)); if (!sourcePanel) { return of({ data: [], error: { message: 'Could not find source panel' } }); @@ -295,11 +295,6 @@ export class DashboardDatasource extends DataSourceApi { }; } - private findSourcePanel(scene: SceneObject, panelId: number) { - // We're trying to find the original panel, not a cloned one, since `panelId` alone cannot resolve clones - return findOriginalVizPanelByKey(scene, getVizPanelKeyForPanelId(panelId)); - } - private emitFirstLoadedDataIfMixedDS( requestId: string ): (source: Observable) => Observable { diff --git a/yarn.lock b/yarn.lock index a27155c3ac9..ab8f5754642 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3586,11 +3586,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:6.29.7": - version: 6.29.7 - resolution: "@grafana/scenes-react@npm:6.29.7" +"@grafana/scenes-react@npm:^6.30.0": + version: 6.30.0 + resolution: "@grafana/scenes-react@npm:6.30.0" dependencies: - "@grafana/scenes": "npm:6.29.7" + "@grafana/scenes": "npm:6.30.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3602,13 +3602,13 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/d3db42b8face33a871cc2b983b00890b4a7b9cf685b06dd02df730036f9c13f1cdbc0dcb578453a0ce29381bdceb26d2a07ae4be77a8f753677dde1560eb491f + checksum: 10/107b930aaf88945cbc51601443190357d9733bd3e9063fa7d2fd7496ad772e25f7cbddd34fd6a2adeb5ae85d39d03cecf8d2ad3d74a351e61fedabb17dfce82a languageName: node linkType: hard -"@grafana/scenes@npm:6.29.7": - version: 6.29.7 - resolution: "@grafana/scenes@npm:6.29.7" +"@grafana/scenes@npm:6.30.0, @grafana/scenes@npm:^6.30.0": + version: 6.30.0 + resolution: "@grafana/scenes@npm:6.30.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3628,7 +3628,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/a2bbda33c78cd568d6333e033e1b587917ee4ca00d8f69e3707197d6712beba1d1b9244f05259bc4cce7416b9ec67d24eddaa1e6073695e2b4a7af1a37253174 + checksum: 10/b1036a1d8c531b3e197c3de276c4fe4a7092fcd27e1fd350ff6530cabb8eb5436a6184840ef6a4e6747510326e58c9a73d54b0c36f8ee7d20f8fd6eaccea8d75 languageName: node linkType: hard @@ -18313,8 +18313,8 @@ __metadata: "@grafana/plugin-ui": "npm:0.10.9" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:6.29.7" - "@grafana/scenes-react": "npm:6.29.7" + "@grafana/scenes": "npm:^6.30.0" + "@grafana/scenes-react": "npm:^6.30.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" From 35ecb3330bbd09f283844056db2b939a4a9b0c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Wed, 20 Aug 2025 10:48:05 +0200 Subject: [PATCH 03/53] apiserver: openapi: only log errors when an error happened (#109780) --- pkg/services/apiserver/builder/openapi.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/services/apiserver/builder/openapi.go b/pkg/services/apiserver/builder/openapi.go index cde3548a136..5326ea071e2 100644 --- a/pkg/services/apiserver/builder/openapi.go +++ b/pkg/services/apiserver/builder/openapi.go @@ -34,7 +34,9 @@ func GetOpenAPIDefinitions(builders []APIGroupBuilder, additionalGetters ...open return bytes.Equal(aa, bb) }, ) - logging.DefaultLogger.Error("error initializing DataQuery apiequality", "err", err) + if err != nil { + logging.DefaultLogger.Error("error initializing DataQuery apiequality", "err", err) + } }) return func(ref openapi.ReferenceCallback) map[string]openapi.OpenAPIDefinition { From 62fbeb35c1db8a1b1050bdb046267123df8956a5 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 20 Aug 2025 09:55:12 +0100 Subject: [PATCH 04/53] Chore: Replace `config.bootData.user` with `contextSrv` (#109686) * replace config.bootData.user with contextSrv * fix unit tests * fix mutating context object in test * kick CI --- public/app/app.ts | 14 +++++++------- .../components/AppChrome/MegaMenu/hooks.ts | 4 +++- .../AppChrome/TopBar/SignInLink.test.tsx | 1 + .../navigation/patch/interceptLinkClicks.ts | 5 +++-- .../backends/analytics/RudderstackBackend.ts | 5 +++-- public/app/core/services/impression_srv.ts | 5 +++-- public/app/core/specs/impression_srv.test.ts | 18 ++++++++++-------- .../alerting/unified/Silences.test.tsx | 8 +++++--- .../components/silences/SilencesEditor.tsx | 5 +++-- .../unified/components/silences/utils.ts | 5 +++-- public/app/features/alerting/unified/mocks.ts | 3 +-- public/app/features/apiserver/client.ts | 2 +- .../buildNewDashboardSaveModel.ts | 6 ++++-- .../sharing/ExportButton/utils.ts | 3 ++- .../sharing/ShareLinkTab.test.tsx | 3 ++- .../SaveDashboard/SaveDashboardDrawer.test.tsx | 5 ----- .../components/ShareModal/ShareEmbed.test.tsx | 11 +++++------ .../components/ShareModal/ShareLink.test.tsx | 5 +++-- .../dashboard/components/ShareModal/utils.ts | 3 ++- .../dashboard/state/DashboardMigrator.test.ts | 2 -- .../features/dashboard/state/initDashboard.ts | 3 ++- .../dashboard/utils/getPanelMenu.test.ts | 1 + public/app/features/explore/Explore.test.tsx | 1 + .../explore/spec/datasourceState.test.tsx | 1 + .../explore/spec/interpolation.test.tsx | 1 + .../app/features/explore/spec/split.test.tsx | 1 + .../app/features/org/OrgDetailsPage.test.tsx | 1 + .../features/playlist/PlaylistPage.test.tsx | 1 + .../plugins/admin/pages/Browse.test.tsx | 1 - .../plugins/admin/pages/PluginDetails.test.tsx | 1 + .../app/features/plugins/admin/permissions.ts | 3 +-- public/app/features/profile/state/reducers.ts | 6 +++--- .../ServiceAccountPage.test.tsx | 1 + .../ServiceAccountsListPage.test.tsx | 1 + 34 files changed, 77 insertions(+), 59 deletions(-) diff --git a/public/app/app.ts b/public/app/app.ts index 01fc1068130..05f6a2e0ab0 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -136,11 +136,11 @@ export class GrafanaApp { window.parent.postMessage('GrafanaAppInit', '*'); const regionalFormat = config.featureToggles.localeFormatPreference ? config.regionalFormat - : config.bootData.user.language; + : contextSrv.user.language; const initI18nPromise = initializeI18n( { - language: config.bootData.user.language, + language: contextSrv.user.language, ns: NAMESPACES, module: loadTranslations, }, @@ -163,7 +163,7 @@ export class GrafanaApp { startMeasure('frontend_app_init'); setLocale(config.regionalFormat); - setWeekStart(config.bootData.user.weekStart); + setWeekStart(contextSrv.user.weekStart); setPanelRenderer(PanelRenderer); setPluginPage(PluginPage); setFolderPicker(LazyFolderPicker); @@ -171,7 +171,7 @@ export class GrafanaApp { setLocationSrv(locationService); setCorrelationsService(new CorrelationsService()); setEmbeddedDashboard(EmbeddedDashboardLazy); - setTimeZoneResolver(() => config.bootData.user.timezone); + setTimeZoneResolver(() => contextSrv.user.timezone); initGrafanaLive(); setCurrentUser(contextSrv.user); @@ -372,8 +372,8 @@ async function initEchoSrv() { }, buildInfo: config.buildInfo, user: { - id: String(config.bootData.user?.id), - email: config.bootData.user?.email, + id: String(contextSrv.user?.id), + email: contextSrv.user?.email, }, ignoreUrls: rudderstackUrls, }) @@ -405,7 +405,7 @@ async function initEchoSrv() { new RudderstackBackend({ writeKey: config.rudderstackWriteKey, dataPlaneUrl: config.rudderstackDataPlaneUrl, - user: config.bootData.user, + user: contextSrv.user, sdkUrl: config.rudderstackSdkUrl, configUrl: config.rudderstackConfigUrl, integrationsUrl: config.rudderstackIntegrationsUrl, diff --git a/public/app/core/components/AppChrome/MegaMenu/hooks.ts b/public/app/core/components/AppChrome/MegaMenu/hooks.ts index 35c1f4be482..db8bb2807f9 100644 --- a/public/app/core/components/AppChrome/MegaMenu/hooks.ts +++ b/public/app/core/components/AppChrome/MegaMenu/hooks.ts @@ -3,8 +3,10 @@ import { useMemo } from 'react'; import { config } from '@grafana/runtime'; import { useGetUserPreferencesQuery } from 'app/features/preferences/api'; +import { contextSrv } from '../../../services/context_srv'; + export const usePinnedItems = () => { - const preferences = useGetUserPreferencesQuery(undefined, { skip: !config.bootData.user.isSignedIn }); + const preferences = useGetUserPreferencesQuery(undefined, { skip: !contextSrv.user.isSignedIn }); const pinnedItems = useMemo(() => preferences.data?.navbar?.bookmarkUrls || [], [preferences]); if (config.featureToggles.pinNavItems) { diff --git a/public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx b/public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx index 4effc8dc94b..c6903685aef 100644 --- a/public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx +++ b/public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx @@ -8,6 +8,7 @@ import { SignInLink } from './SignInLink'; jest.mock('app/core/services/context_srv', () => ({ contextSrv: { + ...jest.requireActual('app/core/services/context_srv').contextSrv, setRedirectToUrl: jest.fn(), }, })); diff --git a/public/app/core/navigation/patch/interceptLinkClicks.ts b/public/app/core/navigation/patch/interceptLinkClicks.ts index 7a83a6f5302..0421f599f16 100644 --- a/public/app/core/navigation/patch/interceptLinkClicks.ts +++ b/public/app/core/navigation/patch/interceptLinkClicks.ts @@ -1,6 +1,7 @@ import { locationUtil, urlUtil } from '@grafana/data'; import { locationService, navigationLogger } from '@grafana/runtime'; -import { config } from 'app/core/config'; + +import { contextSrv } from '../../services/context_srv'; export function interceptLinkClicks(e: MouseEvent) { const anchor = e.target instanceof Element && getParentAnchor(e.target); @@ -16,7 +17,7 @@ export function interceptLinkClicks(e: MouseEvent) { if (href && !target) { const params = urlUtil.parseKeyValue(href.split('?')[1]); - const orgIdChange = params.orgId && Number(params.orgId) !== config.bootData.user.orgId; + const orgIdChange = params.orgId && Number(params.orgId) !== contextSrv.user.orgId; navigationLogger('utils', false, 'intercepting link click', e); e.preventDefault(); diff --git a/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts index 5b3c6c97a03..03ea4baef00 100644 --- a/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts +++ b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts @@ -1,4 +1,4 @@ -import { BuildInfo, CurrentUserDTO } from '@grafana/data'; +import { BuildInfo } from '@grafana/data'; import { EchoBackend, EchoEventType, @@ -8,6 +8,7 @@ import { PageviewEchoEvent, } from '@grafana/runtime'; +import { User } from '../../../context_srv'; import { loadScript } from '../../utils'; type Properties = Record; @@ -37,7 +38,7 @@ export interface RudderstackBackendOptions { writeKey: string; dataPlaneUrl: string; buildInfo: BuildInfo; - user?: CurrentUserDTO; + user?: User; sdkUrl?: string; configUrl?: string; integrationsUrl?: string; diff --git a/public/app/core/services/impression_srv.ts b/public/app/core/services/impression_srv.ts index 3dcd3bdd39c..2801049a10e 100644 --- a/public/app/core/services/impression_srv.ts +++ b/public/app/core/services/impression_srv.ts @@ -1,9 +1,10 @@ import { filter, isArray, isNumber, isString } from 'lodash'; import { getBackendSrv } from '@grafana/runtime'; -import config from 'app/core/config'; import store from 'app/core/store'; +import { contextSrv } from './context_srv'; + export class ImpressionSrv { constructor() {} @@ -58,7 +59,7 @@ export class ImpressionSrv { } impressionKey() { - return 'dashboard_impressions-' + config.bootData.user.orgId; + return 'dashboard_impressions-' + contextSrv.user.orgId; } } diff --git a/public/app/core/specs/impression_srv.test.ts b/public/app/core/specs/impression_srv.test.ts index 32a479c36c3..692ce9435f8 100644 --- a/public/app/core/specs/impression_srv.test.ts +++ b/public/app/core/specs/impression_srv.test.ts @@ -7,14 +7,16 @@ jest.mock('@grafana/runtime', () => { return { ...originalRuntime, getBackendSrv: mockBackendSrv, - config: { - ...originalRuntime.config, - bootData: { - ...originalRuntime.config.bootData, - user: { - ...originalRuntime.config.bootData.user, - orgId: 'testOrgId', - }, + }; +}); + +jest.mock('app/core/services/context_srv', () => { + return { + contextSrv: { + ...jest.requireActual('app/core/services/context_srv').contextSrv, + user: { + ...jest.requireActual('app/core/services/context_srv').contextSrv.user, + orgId: 'testOrgId', }, }, }; diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 7f1deda8289..4ff786b2bc4 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -4,7 +4,7 @@ import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testin import { dateTime } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { config, locationService } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; import { waitForServerRequest } from 'app/features/alerting/unified/mocks/server/events'; import { @@ -17,6 +17,8 @@ import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/cons import { MatcherOperator, SilenceState } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types/accessControl'; +import { contextSrv } from '../../../core/services/context_srv'; + import NewSilencePage from './NewSilencePage'; import ExistingSilenceEditorPage from './components/silences/SilencesEditor'; import SilencesTablePage from './components/silences/SilencesTable'; @@ -89,8 +91,8 @@ const ui = { }; const setUserLogged = (isLogged: boolean) => { - config.bootData.user.isSignedIn = isLogged; - config.bootData.user.name = isLogged ? 'admin' : ''; + contextSrv.user.isSignedIn = isLogged; + contextSrv.user.name = isLogged ? 'admin' : ''; }; const enterSilenceLabel = async (index: number, name: string, matcher: MatcherOperator, value: string) => { diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 30154485057..021132493cb 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -14,7 +14,7 @@ import { parseDuration, } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { config, isFetchError, locationService } from '@grafana/runtime'; +import { isFetchError, locationService } from '@grafana/runtime'; import { Alert, Button, @@ -32,6 +32,7 @@ import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/cons import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource'; import { MatcherOperator, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; +import { contextSrv } from '../../../../../core/services/context_srv'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { SilenceFormFields } from '../../types/silence-form'; @@ -216,7 +217,7 @@ export const SilencesEditor = ({ [clearErrors, duration, endsAt, prevDuration, setValue, startsAt] ); - const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name); + const userLogged = Boolean(contextSrv.user.isSignedIn && contextSrv.user.name); return ( diff --git a/public/app/features/alerting/unified/components/silences/utils.ts b/public/app/features/alerting/unified/components/silences/utils.ts index 8c8088b9db8..a0909f7e647 100644 --- a/public/app/features/alerting/unified/components/silences/utils.ts +++ b/public/app/features/alerting/unified/components/silences/utils.ts @@ -1,11 +1,12 @@ import { DefaultTimeZone, addDurationToDate, dateTime, intervalToAbbreviatedDurationString } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { SilenceFormFields } from 'app/features/alerting/unified/types/silence-form'; import { matcherToMatcherField } from 'app/features/alerting/unified/utils/alertmanager'; import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants'; import { parseQueryParamMatchers } from 'app/features/alerting/unified/utils/matchers'; import { MatcherOperator, Silence } from 'app/plugins/datasource/alertmanager/types'; +import { contextSrv } from '../../../../../core/services/context_srv'; + /** * Parse query params and return default silence form values */ @@ -68,7 +69,7 @@ export const getDefaultSilenceFormValues = (partial?: Partial startsAt: now.toISOString(), endsAt: endsAt.toISOString(), comment: `created ${dateTime().format('YYYY-MM-DD HH:mm')}`, - createdBy: config.bootData.user.name, + createdBy: contextSrv.user.name, duration: '2h', isRegex: false, matcherName: '', diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 6378aa33ddc..ff0a8f11d2e 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -9,7 +9,6 @@ import { PluginExtensionTypes, ReducerID, } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { DataQuery, defaultDashboard } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/grafanaRuler'; @@ -338,7 +337,7 @@ export const mockSilence = (partial: Partial = {}): Silence => { startsAt: new Date().toISOString(), endsAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), updatedAt: new Date().toISOString(), - createdBy: config.bootData.user.name || 'admin', + createdBy: contextSrv.user.name || 'admin', comment: 'Silence noisy alerts', status: { state: SilenceState.Active, diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index b4401ba0244..9aeda6c53ea 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -58,7 +58,7 @@ export class ScopedResourceClient implements .getStream>({ scope: LiveChannelScope.Watch, namespace: this.gvr.group, - path: `${this.gvr.version}/${this.gvr.resource}${query}/${config.bootData.user.uid}`, + path: `${this.gvr.version}/${this.gvr.resource}${query}/${contextSrv.user.uid}`, }) .pipe( filter((event) => isLiveChannelMessageEvent(event)), diff --git a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts index f690d58b035..60685a1b206 100644 --- a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts @@ -15,6 +15,8 @@ import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DashboardDTO } from 'app/types/dashboard'; +import { contextSrv } from '../../../core/services/context_srv'; + export async function buildNewDashboardSaveModel(urlFolderUid?: string): Promise { let variablesList = defaultDashboard.templating?.list; @@ -58,7 +60,7 @@ export async function buildNewDashboardSaveModel(urlFolderUid?: string): Promise uid: '', title: t('dashboard-scene.build-new-dashboard-save-model.data.title.new-dashboard', 'New dashboard'), panels: [], - timezone: config.bootData.user?.timezone || defaultDashboard.timezone, + timezone: contextSrv.user?.timezone || defaultDashboard.timezone, }, }; @@ -123,7 +125,7 @@ export async function buildNewDashboardSaveModelV2( title: t('dashboard-scene.build-new-dashboard-save-model-v2.data.title.new-dashboard', 'New dashboard'), timeSettings: { ...defaultTimeSettingsSpec(), - timezone: config.bootData.user?.timezone || defaultTimeSettingsSpec().timezone, + timezone: contextSrv.user?.timezone || defaultTimeSettingsSpec().timezone, }, }, access: { diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts b/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts index f9fb9e5f04b..8870de88366 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts +++ b/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts @@ -3,6 +3,7 @@ import { lastValueFrom } from 'rxjs'; import { config, getBackendSrv } from '@grafana/runtime'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; +import { contextSrv } from '../../../../core/services/context_srv'; import { DashboardScene } from '../../scene/DashboardScene'; /** @@ -50,7 +51,7 @@ export async function generateDashboardImage({ scale, kiosk: true, hideNav: true, - orgId: String(config.bootData.user.orgId), + orgId: String(contextSrv.user.orgId), fullPageImage: true, }, }); diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx index 74b7952ab02..d50de744de1 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx @@ -8,6 +8,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { config, locationService, setPluginImportUtils } from '@grafana/runtime'; import { LocalValueVariable, SceneTimeRange, SceneVariableSet, VizPanel } from '@grafana/scenes'; +import { contextSrv } from '../../../core/services/context_srv'; import { DashboardScene } from '../scene/DashboardScene'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { activateFullSceneTree } from '../utils/test-utils'; @@ -36,7 +37,7 @@ describe('ShareLinkTab', () => { config.appUrl = 'http://dashboards.grafana.com/grafana/'; config.rendererAvailable = true; - config.bootData.user.orgId = 1; + contextSrv.user.orgId = 1; config.featureToggles.dashboardSceneForViewers = true; locationService.push('/d/dash-1?from=now-6h&to=now'); }); diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx index 5f51e2094d5..7e635cf29f6 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.test.tsx @@ -11,11 +11,6 @@ import { SaveDashboardDrawer } from './SaveDashboardDrawer'; const saveDashboardMutationMock = jest.fn(); -jest.mock('app/core/core', () => ({ - ...jest.requireActual('app/core/core'), - contextSrv: {}, -})); - jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({ ...jest.requireActual('app/features/browse-dashboards/api/browseDashboardsAPI'), useSaveDashboardMutation: () => [saveDashboardMutationMock], diff --git a/public/app/features/dashboard/components/ShareModal/ShareEmbed.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareEmbed.test.tsx index 377671419df..59080841b65 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareEmbed.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareEmbed.test.tsx @@ -4,6 +4,7 @@ import { BootData } from '@grafana/data'; import { setEchoSrv } from '@grafana/runtime'; import config from 'app/core/config'; +import { contextSrv, User } from '../../../../core/services/context_srv'; import { Echo } from '../../../../core/services/echo/Echo'; import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; @@ -19,8 +20,8 @@ jest.mock('app/features/dashboard/services/TimeSrv', () => ({ jest.mock('app/core/services/context_srv', () => ({ contextSrv: { + ...jest.requireActual('app/core/services/context_srv').contextSrv, sidemenu: true, - user: {}, isSignedIn: false, isGrafanaAdmin: false, isEditor: false, @@ -56,11 +57,9 @@ describe('ShareEmbed', () => { originalBootData = config.bootData; config.appUrl = 'http://dashboards.grafana.com/'; - config.bootData = { - user: { - orgId: 1, - }, - } as BootData; + contextSrv.user = { + orgId: 1, + } as User; }); afterAll(() => { diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx index 780e71bc494..eef8b965a98 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -7,6 +7,7 @@ import { setEchoSrv, setTemplateSrv } from '@grafana/runtime'; import config from 'app/core/config'; import { initTemplateSrv } from '../../../../../test/helpers/initTemplateSrv'; +import { contextSrv } from '../../../../core/services/context_srv'; import { Echo } from '../../../../core/services/echo/Echo'; import { variableAdapters } from '../../../variables/adapters'; import { createQueryVariableAdapter } from '../../../variables/query/adapter'; @@ -79,7 +80,7 @@ describe('ShareModal', () => { }); mockLocationHref('http://server/#!/test'); config.rendererAvailable = true; - config.bootData.user.orgId = 1; + contextSrv.user.orgId = 1; props = { panel: new PanelModel({ id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } }), dashboard: createDashboardModelFixture({ @@ -186,7 +187,7 @@ describe('when appUrl is set in the grafana config', () => { originalBootData = config.bootData; config.appUrl = 'http://dashboards.grafana.com/'; config.rendererAvailable = true; - config.bootData.user.orgId = 1; + contextSrv.user.orgId = 1; }); afterAll(() => { diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts index 5770afefafe..e60deafe3a5 100644 --- a/public/app/features/dashboard/components/ShareModal/utils.ts +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -3,6 +3,7 @@ import { config } from '@grafana/runtime'; import { createShortLink } from 'app/core/utils/shortLinks'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { contextSrv } from '../../../../core/services/context_srv'; import { PanelModel } from '../../state/PanelModel'; export interface BuildParamsArgs { @@ -22,7 +23,7 @@ export function buildParams({ timeFrom, search = window.location.search, range = getTimeSrv().timeRange(), - orgId = config.bootData.user.orgId, + orgId = contextSrv.user.orgId, }: BuildParamsArgs): URLSearchParams { const searchParams = new URLSearchParams(search); diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index e82c2a723ba..83d7b9fc8a7 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -14,8 +14,6 @@ import { PanelModel } from '../state/PanelModel'; import { DASHBOARD_SCHEMA_VERSION } from './DashboardMigrator'; -jest.mock('app/core/services/context_srv', () => ({})); - const dataSources = { prom: mockDataSource({ name: 'prom', diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 348bb55616c..774e73e37ad 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -30,6 +30,7 @@ import { } from 'app/types/dashboard'; import { StoreState, ThunkDispatch, ThunkResult } from 'app/types/store'; +import { contextSrv } from '../../../core/services/context_srv'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { initVariablesTransaction } from '../../variables/state/actions'; import { getIfExistsLastKey } from '../../variables/state/selectors'; @@ -285,7 +286,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { if (dashboard.weekStart !== '' && dashboard.weekStart !== undefined) { setWeekStart(dashboard.weekStart); } else { - setWeekStart(config.bootData.user.weekStart); + setWeekStart(contextSrv.user.weekStart); } // Propagate an app-wide event about the dashboard being loaded diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index 3a18ce049dc..6f458a8b043 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -15,6 +15,7 @@ import { getPanelMenu } from './getPanelMenu'; jest.mock('app/core/services/context_srv', () => ({ contextSrv: { + ...jest.requireActual('app/core/services/context_srv').contextSrv, hasAccessToExplore: () => true, hasPermission: jest.fn(), }, diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 3c0936eb7f5..a59ece64805 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -125,6 +125,7 @@ jest.mock('@grafana/runtime', () => ({ jest.mock('app/core/core', () => ({ contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, hasPermission: () => true, getValidIntervals: (defaultIntervals: string[]) => defaultIntervals, }, diff --git a/public/app/features/explore/spec/datasourceState.test.tsx b/public/app/features/explore/spec/datasourceState.test.tsx index a1f1e353a85..c8fffa5d499 100644 --- a/public/app/features/explore/spec/datasourceState.test.tsx +++ b/public/app/features/explore/spec/datasourceState.test.tsx @@ -26,6 +26,7 @@ jest.mock('react-virtualized-auto-sizer', () => { jest.mock('app/core/core', () => ({ contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, hasPermission: () => true, getValidIntervals: (defaultIntervals: string[]) => defaultIntervals, }, diff --git a/public/app/features/explore/spec/interpolation.test.tsx b/public/app/features/explore/spec/interpolation.test.tsx index d708a231a3a..7e7c5b1effe 100644 --- a/public/app/features/explore/spec/interpolation.test.tsx +++ b/public/app/features/explore/spec/interpolation.test.tsx @@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({ jest.mock('app/core/core', () => ({ contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, hasPermission: () => true, getValidIntervals: (defaultIntervals: string[]) => defaultIntervals, }, diff --git a/public/app/features/explore/spec/split.test.tsx b/public/app/features/explore/spec/split.test.tsx index 0bd25b92b1c..a595195c4db 100644 --- a/public/app/features/explore/spec/split.test.tsx +++ b/public/app/features/explore/spec/split.test.tsx @@ -15,6 +15,7 @@ const testEventBus = new EventBusSrv(); jest.mock('app/core/core', () => { return { contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, hasPermission: () => true, getValidIntervals: (defaultIntervals: string[]) => defaultIntervals, }, diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx index c71bfd1eff8..19375f769c5 100644 --- a/public/app/features/org/OrgDetailsPage.test.tsx +++ b/public/app/features/org/OrgDetailsPage.test.tsx @@ -15,6 +15,7 @@ jest.mock('app/core/core', () => { return { ...jest.requireActual('app/core/core'), contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, hasPermission: () => true, }, }; diff --git a/public/app/features/playlist/PlaylistPage.test.tsx b/public/app/features/playlist/PlaylistPage.test.tsx index 6ef4f8a0327..0abb7bc9513 100644 --- a/public/app/features/playlist/PlaylistPage.test.tsx +++ b/public/app/features/playlist/PlaylistPage.test.tsx @@ -16,6 +16,7 @@ jest.mock('@grafana/runtime', () => ({ jest.mock('app/core/services/context_srv', () => ({ contextSrv: { + ...jest.requireActual('app/core/services/context_srv').contextSrv, isEditor: true, }, })); diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index 6b6d51a2bb1..a76e297dcda 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -15,7 +15,6 @@ jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); const mockedRuntime = { ...original }; - mockedRuntime.config.bootData.user.isGrafanaAdmin = true; mockedRuntime.config.buildInfo.version = 'v8.1.0'; return mockedRuntime; diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index dcd34e386fc..da1ae622135 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -40,6 +40,7 @@ jest.mock('../hooks/usePluginConfig.tsx', () => ({ usePluginConfig: jest.fn(() = jest.mock('app/core/core', () => ({ contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, hasPermission: (action: string) => true, hasPermissionInMetadata: (action: string, object: WithAccessControlMetadata) => true, }, diff --git a/public/app/features/plugins/admin/permissions.ts b/public/app/features/plugins/admin/permissions.ts index 4ea5eccdd34..c3c8262adc2 100644 --- a/public/app/features/plugins/admin/permissions.ts +++ b/public/app/features/plugins/admin/permissions.ts @@ -1,9 +1,8 @@ -import { config } from 'app/core/config'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; export function isGrafanaAdmin(): boolean { - return config.bootData.user.isGrafanaAdmin; + return contextSrv.user.isGrafanaAdmin; } export function isOrgAdmin() { diff --git a/public/app/features/profile/state/reducers.ts b/public/app/features/profile/state/reducers.ts index 6d1bb9096e6..fde01903007 100644 --- a/public/app/features/profile/state/reducers.ts +++ b/public/app/features/profile/state/reducers.ts @@ -25,9 +25,9 @@ export interface UserState { } export const initialUserState: UserState = { - orgId: config.bootData.user.orgId, - timeZone: config.bootData.user.timezone, - weekStart: config.bootData.user.weekStart, + orgId: contextSrv.user.orgId, + timeZone: contextSrv.user.timezone, + weekStart: contextSrv.user.weekStart, fiscalYearStartMonth: 0, orgsAreLoading: false, sessionsAreLoading: false, diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx index 4de16f29479..c1f768b525c 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx @@ -10,6 +10,7 @@ import { ServiceAccountPageUnconnected, Props } from './ServiceAccountPage'; jest.mock('app/core/core', () => ({ contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, licensedAccessControlEnabled: () => false, hasPermission: () => true, hasPermissionInMetadata: () => false, diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx index d4b870c2921..1be6a388016 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx @@ -9,6 +9,7 @@ import { Props, ServiceAccountsListPageUnconnected } from './ServiceAccountsList jest.mock('app/core/core', () => ({ contextSrv: { + ...jest.requireActual('app/core/core').contextSrv, licensedAccessControlEnabled: () => false, hasPermission: () => true, hasPermissionInMetadata: () => true, From c37a03263f85562c5f549e3693cae0f0e0ad6d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Wed, 20 Aug 2025 11:02:19 +0200 Subject: [PATCH 05/53] Provisioning: Fix Bug Blocking Changing Pull Target During Onboarding (#109892) * Fix bug changing target for unsynced repository * Fix linting --- pkg/registry/apis/provisioning/register.go | 3 +- pkg/tests/apis/provisioning/helper_test.go | 15 ++++++---- .../apis/provisioning/repository_test.go | 30 +++++++++++++++++-- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 79559bb2238..e34599c586b 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -286,7 +286,6 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { Namespace: a.GetNamespace(), Subresource: a.GetSubresource(), }) - if err != nil { return authorizer.DecisionDeny, "failed to perform authorization", err } @@ -622,7 +621,7 @@ func (b *APIBuilder) verifyAgaintsExistingRepositories(cfg *provisioning.Reposit } else { // Folder sync cannot be created if an instance repository exists for _, v := range all { - if v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance { + if v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance && v.Name != cfg.Name { return field.Forbidden(field.NewPath("spec", "sync", "target"), "Cannot create folder repository when instance repository exists: "+v.Name) } diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 554ad36bff8..48956371779 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -468,6 +468,7 @@ type TestRepo struct { Copies map[string]string ExpectedDashboards int ExpectedFolders int + SkipSync bool } func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) { @@ -486,7 +487,7 @@ func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) { templateVars := map[string]any{ "Name": repo.Name, - "SyncEnabled": true, + "SyncEnabled": !repo.SkipSync, "SyncTarget": repo.Target, } if repo.Path != "" { @@ -512,11 +513,13 @@ func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) { } } - // Trigger and wait for initial sync to populate resources - h.SyncAndWait(t, repo.Name, nil) - - // Debug state after initial sync - h.DebugState(t, repo.Name, "AFTER INITIAL SYNC") + if !repo.SkipSync { + // Trigger and wait for initial sync to populate resources + h.SyncAndWait(t, repo.Name, nil) + h.DebugState(t, repo.Name, "AFTER INITIAL SYNC") + } else { + h.DebugState(t, repo.Name, "AFTER REPO CREATION") + } // Verify initial state dashboards, err := h.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{}) diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index e3213517869..95ebf922dd1 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -362,9 +362,6 @@ func TestIntegrationProvisioning_InstanceSyncValidation(t *testing.T) { ctx := context.Background() t.Run("single instance sync is allowed", func(t *testing.T) { - // Ensure clean state - helper.CleanupAllRepos(t) - repoName := "instance-repo-single" testRepo := TestRepo{ Name: repoName, @@ -381,6 +378,33 @@ func TestIntegrationProvisioning_InstanceSyncValidation(t *testing.T) { helper.CleanupAllRepos(t) }) + t.Run("change between folder and instance sync for the same repository if no previous sync happened", func(t *testing.T) { + // Ensure clean state + helper.CleanupAllRepos(t) + + repoName := "instance-repo-change" + testRepo := TestRepo{ + Name: repoName, + Target: "instance", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 0, + SkipSync: true, // To avoid initial sync and stats + } + helper.CreateRepo(t, testRepo) + + // Change from instance to folder sync + repo, err := helper.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{}) + require.NoError(t, err, "failed to get repository") + err = unstructured.SetNestedField(repo.Object, "folder", "spec", "sync", "target") + require.NoError(t, err, "failed to set syncTarget to folder") + _, err = helper.Repositories.Resource.Update(ctx, repo, metav1.UpdateOptions{FieldValidation: "Strict"}) + require.NoError(t, err, "failed to update repository to folder sync") + + // Clean up at end of test + helper.CleanupAllRepos(t) + }) + t.Run("instance sync rejected when any other repository exists", func(t *testing.T) { // Ensure clean state helper.CleanupAllRepos(t) From fa81fae1e384a25cbd5057cb1f7479f254463a13 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 20 Aug 2025 12:05:41 +0300 Subject: [PATCH 06/53] Provisioning: Add inline secure values to repository schema (#109594) --- .../pkg/apis/provisioning/v0alpha1/types.go | 14 +++ .../v0alpha1/zz_generated.deepcopy.go | 19 ++++ .../v0alpha1/zz_generated.openapi.go | 38 +++++++- .../provisioning/v0alpha1/repository.go | 9 ++ .../provisioning/v0alpha1/securevalues.go | 38 ++++++++ .../pkg/generated/applyconfiguration/utils.go | 2 + pkg/extensions/enterprise_imports.go | 2 +- pkg/server/test_env.go | 13 ++- pkg/server/wire_gen.go | 2 +- .../provisioning.grafana.app-v0alpha1.json | 74 ++++++++++++++ pkg/tests/apis/openapi_test.go | 30 +++--- pkg/tests/apis/provisioning/secrets_test.go | 97 ++++++++++++++++++- .../github-with-inline-secrets.json.tmpl | 23 +++++ 13 files changed, 333 insertions(+), 28 deletions(-) create mode 100644 apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/securevalues.go create mode 100644 pkg/tests/apis/provisioning/testdata/github-with-inline-secrets.json.tmpl diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go index 219db2907fb..dd678117d9a 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go @@ -17,9 +17,23 @@ type Repository struct { metav1.ObjectMeta `json:"metadata,omitempty"` Spec RepositorySpec `json:"spec,omitempty"` + Secure SecureValues `json:"secure,omitzero,omitempty"` Status RepositoryStatus `json:"status,omitempty"` } +// NOT YET USED FOR REAL -- testing secure value workflow +type SecureValues struct { + // Token used to connect the configured repository + Token common.InlineSecureValue `json:"token,omitzero,omitempty"` + + // Some webhooks (github) require a secret key value + WebhookSecret common.InlineSecureValue `json:"webhookSecret,omitzero,omitempty"` +} + +func (v SecureValues) IsZero() bool { + return v.Token.IsZero() && v.WebhookSecret.IsZero() +} + type LocalRepositoryConfig struct { Path string `json:"path,omitempty"` } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 868437c9e85..5e09384fc9f 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -665,6 +665,7 @@ func (in *Repository) DeepCopyInto(out *Repository) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) + out.Secure = in.Secure in.Status.DeepCopyInto(&out.Status) return } @@ -1085,6 +1086,24 @@ func (in *ResourceWrapper) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecureValues) DeepCopyInto(out *SecureValues) { + *out = *in + out.Token = in.Token + out.WebhookSecret = in.WebhookSecret + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecureValues. +func (in *SecureValues) DeepCopy() *SecureValues { + if in == nil { + return nil + } + out := new(SecureValues) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SyncJobOptions) DeepCopyInto(out *SyncJobOptions) { *out = *in diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 0b4c4e42377..a1835c624ce 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -57,6 +57,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceStats": schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceWrapper": schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SecureValues": schema_pkg_apis_provisioning_v0alpha1_SecureValues(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncJobOptions": schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncOptions": schema_pkg_apis_provisioning_v0alpha1_SyncOptions(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncStatus": schema_pkg_apis_provisioning_v0alpha1_SyncStatus(ref), @@ -1435,6 +1436,12 @@ func schema_pkg_apis_provisioning_v0alpha1_Repository(ref common.ReferenceCallba Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositorySpec"), }, }, + "secure": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SecureValues"), + }, + }, "status": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, @@ -1445,7 +1452,7 @@ func schema_pkg_apis_provisioning_v0alpha1_Repository(ref common.ReferenceCallba }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositorySpec", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositorySpec", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryStatus", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SecureValues", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, } } @@ -2359,6 +2366,35 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceC } } +func schema_pkg_apis_provisioning_v0alpha1_SecureValues(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "NOT YET USED FOR REAL -- testing secure value workflow", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "token": { + SchemaProps: spec.SchemaProps{ + Description: "Token used to connect the configured repository", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), + }, + }, + "webhookSecret": { + SchemaProps: spec.SchemaProps{ + Description: "Some webhooks (github) require a secret key value", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"}, + } +} + func schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/repository.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/repository.go index 19a4e828600..6541158cb51 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/repository.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/repository.go @@ -16,6 +16,7 @@ type RepositoryApplyConfiguration struct { v1.TypeMetaApplyConfiguration `json:",inline"` *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` Spec *RepositorySpecApplyConfiguration `json:"spec,omitempty"` + Secure *SecureValuesApplyConfiguration `json:"secure,omitempty"` Status *RepositoryStatusApplyConfiguration `json:"status,omitempty"` } @@ -196,6 +197,14 @@ func (b *RepositoryApplyConfiguration) WithSpec(value *RepositorySpecApplyConfig return b } +// WithSecure sets the Secure field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Secure field is set to the value of the last call. +func (b *RepositoryApplyConfiguration) WithSecure(value *SecureValuesApplyConfiguration) *RepositoryApplyConfiguration { + b.Secure = value + return b +} + // WithStatus sets the Status field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Status field is set to the value of the last call. diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/securevalues.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/securevalues.go new file mode 100644 index 00000000000..f4892cab861 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/securevalues.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// SecureValuesApplyConfiguration represents a declarative configuration of the SecureValues type for use +// with apply. +type SecureValuesApplyConfiguration struct { + Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"` + WebhookSecret *commonv0alpha1.InlineSecureValue `json:"webhookSecret,omitempty"` +} + +// SecureValuesApplyConfiguration constructs a declarative configuration of the SecureValues type for use with +// apply. +func SecureValues() *SecureValuesApplyConfiguration { + return &SecureValuesApplyConfiguration{} +} + +// WithToken sets the Token field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Token field is set to the value of the last call. +func (b *SecureValuesApplyConfiguration) WithToken(value commonv0alpha1.InlineSecureValue) *SecureValuesApplyConfiguration { + b.Token = &value + return b +} + +// WithWebhookSecret sets the WebhookSecret field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the WebhookSecret field is set to the value of the last call. +func (b *SecureValuesApplyConfiguration) WithWebhookSecret(value commonv0alpha1.InlineSecureValue) *SecureValuesApplyConfiguration { + b.WebhookSecret = &value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/utils.go b/apps/provisioning/pkg/generated/applyconfiguration/utils.go index 8725415c377..dce76cfdc04 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/utils.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/utils.go @@ -62,6 +62,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &provisioningv0alpha1.ResourceCountApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("ResourceRef"): return &provisioningv0alpha1.ResourceRefApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("SecureValues"): + return &provisioningv0alpha1.SecureValuesApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("SyncJobOptions"): return &provisioningv0alpha1.SyncJobOptionsApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("SyncOptions"): diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 3585c68ebf3..d33ccf561aa 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -27,6 +27,7 @@ import ( _ "github.com/robfig/cron/v3" _ "github.com/russellhaering/goxmldsig" _ "github.com/spf13/cobra" // used by the standalone apiserver cli + _ "github.com/spyzhov/ajson" _ "github.com/stretchr/testify/require" _ "gocloud.dev/secrets/awskms" _ "gocloud.dev/secrets/azurekeyvault" @@ -52,5 +53,4 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/spyzhov/ajson" ) diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index ff151cf7b60..e1659ebb460 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -1,11 +1,14 @@ package server import ( + "github.com/stretchr/testify/mock" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" + "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/grpcserver" @@ -14,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/web" - "github.com/stretchr/testify/mock" ) func ProvideTestEnv( @@ -34,7 +36,8 @@ func ProvideTestEnv( resourceClient resource.ResourceClient, idService auth.IDService, githubFactory *github.Factory, - repositorySecrets secrets.RepositorySecrets, + decryptService secret.DecryptService, + repositorySecrets secrets.RepositorySecrets, // TODO... remove ) (*TestEnv, error) { return &TestEnv{ TestingT: testingT, @@ -50,7 +53,8 @@ func ProvideTestEnv( ResourceClient: resourceClient, IDService: idService, GitHubFactory: githubFactory, - RepositorySecrets: repositorySecrets, + DecryptService: decryptService, + RepositorySecrets: repositorySecrets, // TODO, remove }, nil } @@ -72,5 +76,6 @@ type TestEnv struct { ResourceClient resource.ResourceClient IDService auth.IDService GitHubFactory *github.Factory - RepositorySecrets secrets.RepositorySecrets + DecryptService secret.DecryptService + RepositorySecrets secrets.RepositorySecrets // NOTE, this will be removed soon } diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 45a5f618e92..7b3c7db6b68 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -1430,7 +1430,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, repositorySecrets) + testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, v3, repositorySecrets) if err != nil { return nil, err } diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 2352079084e..df2e3eddf0f 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -2375,6 +2375,9 @@ "metadata": { "default": {} }, + "secure": { + "default": {} + }, "spec": { "default": {} }, @@ -3464,6 +3467,14 @@ } ] }, + "secure": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.SecureValues" + } + ] + }, "spec": { "default": {}, "allOf": [ @@ -4193,6 +4204,30 @@ } ] }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.SecureValues": { + "description": "NOT YET USED FOR REAL -- testing secure value workflow", + "type": "object", + "properties": { + "token": { + "description": "Token used to connect the configured repository", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue" + } + ] + }, + "webhookSecret": { + "description": "Some webhooks (github) require a secret key value", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue" + } + ] + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.SyncJobOptions": { "type": "object", "required": [ @@ -4406,6 +4441,45 @@ } } }, + "com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue": { + "description": "Allow access to a secure value inside", + "oneOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "create" + ] + }, + { + "required": [ + "remove" + ] + } + ], + "properties": { + "create": { + "description": "Create a secure value -- this is only used for POST/PUT", + "type": "string", + "maxLength": 24576, + "minLength": 1 + }, + "name": { + "description": "Name in the secret service (reference)", + "type": "string", + "maxLength": 253, + "minLength": 1 + }, + "remove": { + "description": "Remove this value from the secure value map Values owned by this resource will be deleted if necessary", + "type": "boolean" + } + }, + "additionalProperties": false + }, "com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.Unstructured": { "type": "object", "additionalProperties": true, diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index 9e5b754c4d9..10abcea4c66 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -1,15 +1,13 @@ package apis import ( - "context" - "encoding/json" "fmt" + "runtime" "testing" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/version" - apimachineryversion "k8s.io/apimachinery/pkg/version" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -38,18 +36,9 @@ func TestIntegrationOpenAPIs(t *testing.T) { t.Run("check valid version response", func(t *testing.T) { disco := h.NewDiscoveryClient() - req := disco.RESTClient().Get(). - Prefix("version"). - SetHeader("Accept", "application/json") - - result := req.Do(context.Background()) - require.NoError(t, result.Error()) - - raw, err := result.Raw() - require.NoError(t, err) - info := apimachineryversion.Info{} - err = json.Unmarshal(raw, &info) + info, err := disco.ServerVersion() require.NoError(t, err) + require.Equal(t, runtime.Version(), info.GoVersion) // Make sure the gitVersion is parsable v, err := version.Parse(info.GitVersion) @@ -57,10 +46,15 @@ func TestIntegrationOpenAPIs(t *testing.T) { require.Equal(t, info.Major, fmt.Sprintf("%d", v.Major())) require.Equal(t, info.Minor, fmt.Sprintf("%d", v.Minor())) - // Check that OpenAPI v2 (used by kubectl) returns properly - v2, err := disco.OpenAPISchema() - require.NoError(t, err, "requesting OpenAPI v2") - require.Equal(t, "Grafana API Server", v2.Info.Title) + // Check the v3 path resolves properly + // NOTE: fetching the v2 schema sometimes returns a 503 in our test infrastructure + // Removing the explicit `OneOf` properties from InlineSecureValue in: + // https://github.com/grafana/grafana/blob/main/pkg/apimachinery/apis/common/v0alpha1/secure_values.go#L78 + // will consistently support V2, however kubectl and everything else continues to work + paths, err := disco.OpenAPIV3().Paths() + + require.NoError(t, err, "requesting OpenAPI v3") + require.NotEmpty(t, paths, "has registered paths") }) dir := "openapi_snapshots" diff --git a/pkg/tests/apis/provisioning/secrets_test.go b/pkg/tests/apis/provisioning/secrets_test.go index 331d4bc3067..ba571fe25bb 100644 --- a/pkg/tests/apis/provisioning/secrets_test.go +++ b/pkg/tests/apis/provisioning/secrets_test.go @@ -9,14 +9,105 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) +func TestIntegrationProvisioning_InlineSecrets(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t, useAppPlatformSecrets) + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + ctx := context.Background() + + decryptService := helper.GetEnv().DecryptService + require.NotNil(t, decryptService, "decrypt service wired properly") + + type expectedField struct { + Path []string + DecryptedValue string // only try decrypting if not empty + } + + tests := []struct { + name string + values map[string]any + inputFile string + expectedFields []expectedField + }{ + { + name: "inline github token encrypted", + values: map[string]any{ + "SecureTokenCreate": "some-token", + "SecureWebhookSecretCreate": "some-secret", + }, + inputFile: "testdata/github-with-inline-secrets.json.tmpl", + expectedFields: []expectedField{ + { + Path: []string{"secure", "token", "name"}, + DecryptedValue: "some-token", + }, + { + Path: []string{"secure", "webhookSecret", "name"}, + DecryptedValue: "some-secret", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := helper.RenderObject(t, test.inputFile, test.values) + obj, err := helper.Repositories.Resource.Create(ctx, input, createOptions) + require.NoError(t, err, "failed to create resource") + require.True(t, strings.HasPrefix(obj.GetName(), "test-"), "created a unique name") + var created []string + + // Move encrypted token mutation + for _, expectedField := range test.expectedFields { + name, found, err := unstructured.NestedString(obj.Object, expectedField.Path...) + require.NoError(t, err, "error getting expected path") + require.True(t, found, expectedField.Path) + require.NotEmpty(t, name, expectedField.Path) + created = append(created, name) + + if expectedField.DecryptedValue != "" { + decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", obj.GetNamespace(), name) + require.NoError(t, err, "decryption error") + require.Len(t, decrypted, 1) + + val := decrypted[name].Value() + require.NotNil(t, val) + require.Equal(t, expectedField.DecryptedValue, val.DangerouslyExposeAndConsumeValue()) + } + } + + err = helper.Repositories.Resource.Delete(ctx, obj.GetName(), metav1.DeleteOptions{}) + require.NoError(t, err, "failed to delete repository") + + // Finalizers will be running async... so we need to wait until it is actually removed + require.Eventually(t, func() bool { + _, err := helper.Repositories.Resource.Get(ctx, obj.GetName(), metav1.GetOptions{}) + return apierrors.IsNotFound(err) + }, time.Second*15, time.Millisecond*300, "should be removed") + + // now check that we can no longer decrypt the requested values + results, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", obj.GetNamespace(), created...) + require.NoError(t, err, "failed to execute decrypt with removed secrets") + for k, v := range results { + require.ErrorContains(t, v.Error(), "not found", "expecting not found error for all secrets: %s", k) + } + }) + } +} + func TestIntegrationProvisioning_LegacySecrets(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/apis/provisioning/testdata/github-with-inline-secrets.json.tmpl b/pkg/tests/apis/provisioning/testdata/github-with-inline-secrets.json.tmpl new file mode 100644 index 00000000000..c867b6472d0 --- /dev/null +++ b/pkg/tests/apis/provisioning/testdata/github-with-inline-secrets.json.tmpl @@ -0,0 +1,23 @@ +{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": { + "generateName": "test-" + }, + "spec": { + "title": "title", + "description": "something", + "type": "github", + "github": { + "url": "{{ or .URL "https://github.com/grafana/grafana-git-sync-demo" }}", + "branch": "{{ or .Branch "integration-test" }}", + "generateDashboardPreviews": {{ if .GenerateDashboardPreviews }} true {{ else }} false {{ end }}, + "token": "{{ or .Token "" }}", + "path": "{{ or .Path "grafana/" }}" + } + }, + "secure": { + "token": { "create": "{{ or .SecureTokenCreate "" }}" }, + "webhookSecret": { "create": "{{ or .SecureWebhookSecretCreate "" }}" } + } +} From 9a065e0c6400259550053b23e844455f7e6caab9 Mon Sep 17 00:00:00 2001 From: Georges Chaudy Date: Wed, 20 Aug 2025 11:09:55 +0200 Subject: [PATCH 07/53] apiserver: enable support for Streaming lists (#109893) enable watchlist --- pkg/services/apiserver/options/extra.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/services/apiserver/options/extra.go b/pkg/services/apiserver/options/extra.go index 5f74f570c47..129b414b3ba 100644 --- a/pkg/services/apiserver/options/extra.go +++ b/pkg/services/apiserver/options/extra.go @@ -45,6 +45,7 @@ func (o *ExtraOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error { logger := slog.New(handler) if err := utilfeature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{ string(genericfeatures.APIServerTracing): false, + string(genericfeatures.WatchList): true, }); err != nil { return err } From 4b59f76738cf82eadcc5e69af041b222ee624648 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 20 Aug 2025 12:41:55 +0300 Subject: [PATCH 08/53] Provisioning: Fix rendering loop in FinishedJobStatus component (#109890) Provisioning: Fix renderign loop in FinishedJobStatus component --- .../provisioning/Job/FinishedJobStatus.tsx | 30 +++++++++---------- .../features/provisioning/Job/JobContent.tsx | 16 +++++----- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx index 60b5ce73917..c8b4dd037fa 100644 --- a/public/app/features/provisioning/Job/FinishedJobStatus.tsx +++ b/public/app/features/provisioning/Job/FinishedJobStatus.tsx @@ -36,6 +36,20 @@ export function FinishedJobStatus({ jobUid, repositoryName, jobType, onStatusCha }, 1000); } + if (retryFailed) { + onStatusChange?.({ + status: 'error', + error: { + title: t('provisioning.job-status.no-job-found', 'No job found'), + message: t( + 'provisioning.job-status.no-job-found-message', + 'The job may have been deleted or could not be retrieved. Cancel the current process and start again.' + ), + }, + }); + return; + } + if (finishedQuery.isSuccess && job?.status) { const { state, message, errors } = job.status; @@ -70,21 +84,7 @@ export function FinishedJobStatus({ jobUid, repositoryName, jobType, onStatusCha clearTimeout(timeoutId); } }; - }, [finishedQuery, job, onStatusChange]); - - if (retryFailed) { - onStatusChange?.({ - status: 'error', - error: { - title: t('provisioning.job-status.no-job-found', 'No job found'), - message: t( - 'provisioning.job-status.no-job-found-message', - 'The job may have been deleted or could not be retrieved. Cancel the current process and start again.' - ), - }, - }); - return null; - } + }, [finishedQuery, job, onStatusChange, retryFailed]); if (!job || finishedQuery.isLoading || finishedQuery.isFetching) { return ( diff --git a/public/app/features/provisioning/Job/JobContent.tsx b/public/app/features/provisioning/Job/JobContent.tsx index 9b58c2f8143..8234a1b4378 100644 --- a/public/app/features/provisioning/Job/JobContent.tsx +++ b/public/app/features/provisioning/Job/JobContent.tsx @@ -76,15 +76,13 @@ export function JobContent({ jobType, job, isFinishedJob = false, onStatusChange {['working', 'pending'].includes(state ?? '') && ( - - - - {message ?? state ?? t('provisioning.job-status.starting', 'Starting...')} - - - )} - {state && !['success', 'error'].includes(state) && ( - + + + + + {message ?? state ?? t('provisioning.job-status.starting', 'Starting...')} + + )} From b4ded7847eb292605d935422c61d24d0490889ba Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 20 Aug 2025 13:08:03 +0300 Subject: [PATCH 09/53] Chore: Disable tui for Nx (#109896) * Nx: Disabled tui * fix --- nx.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nx.json b/nx.json index f5f0322645b..c217e907f44 100644 --- a/nx.json +++ b/nx.json @@ -9,7 +9,8 @@ } }, "tui": { - "autoExit": true + "autoExit": true, + "enabled": false }, "defaultBase": "main" } From ede33327d0810672c4f069530f81a13899139122 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 20 Aug 2025 12:09:08 +0200 Subject: [PATCH 10/53] Devenv: Add tempo and pyroscope to the provisioned data sources (#109853) --- devenv/datasources_docker.yaml | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/devenv/datasources_docker.yaml b/devenv/datasources_docker.yaml index 61c0b3c7ecd..eb76de4ef5b 100644 --- a/devenv/datasources_docker.yaml +++ b/devenv/datasources_docker.yaml @@ -171,6 +171,51 @@ datasources: - name: gdev-loki type: loki + uid: gdev-loki access: proxy url: http://loki:3100 editable: false + + - name: gdev-pyroscope + type: grafana-pyroscope-datasource + uid: gdev-pyroscope + access: proxy + url: http://pyroscope:4040 + editable: false + + - name: gdev-tempo + type: tempo + uid: gdev-tempo + access: proxy + url: http://tempo:3200 + editable: false + correlations: + - targetUID: gdev-loki + label: 'Logs (correlation)' + description: 'Correlation to logs stored in Loki' + config: + type: query + target: + expr: '{ job="job" }' + field: 'traceID' + jsonData: + tracesToLogsV2: + datasourceUid: gdev-loki + spanStartTimeShift: '5m' + spanEndTimeShift: '-5m' + customQuery: true + query: '{filename="/var/log/grafana/grafana.log"} |="$${__span.traceId}"' + tracesToProfiles: + datasourceUid: gdev-pyroscope + profileTypeId: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds' + tracesToMetrics: + datasourceUid: gdev-prometheus + spanStartTimeShift: '1h' + spanEndTimeShift: '-1h' + tags: [{ key: 'job' }] + queries: + - name: 'Metrics' + query: 'sum(rate({$$__tags}[5m]))' + serviceMap: + datasourceUid: 'gdev-prometheus' + histogramType: 'both' # 'classic' or 'native' or 'both' From 91c23988effdba2898af31d15288c99d8aa3f0db Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:50:38 +0000 Subject: [PATCH 11/53] Update scenes to v6.30.1 (#109899) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index ab8f5754642..f3d0457fc2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3587,10 +3587,10 @@ __metadata: linkType: soft "@grafana/scenes-react@npm:^6.30.0": - version: 6.30.0 - resolution: "@grafana/scenes-react@npm:6.30.0" + version: 6.30.1 + resolution: "@grafana/scenes-react@npm:6.30.1" dependencies: - "@grafana/scenes": "npm:6.30.0" + "@grafana/scenes": "npm:6.30.1" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3602,13 +3602,13 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/107b930aaf88945cbc51601443190357d9733bd3e9063fa7d2fd7496ad772e25f7cbddd34fd6a2adeb5ae85d39d03cecf8d2ad3d74a351e61fedabb17dfce82a + checksum: 10/695c61665f38f09f6b49552a32caf1fbe6a3209d9df7039fd9110dcbda9e2031e41d53b7e471021e779bee78f8f27a3978fed96f9e70a07f2157e25f96a81937 languageName: node linkType: hard -"@grafana/scenes@npm:6.30.0, @grafana/scenes@npm:^6.30.0": - version: 6.30.0 - resolution: "@grafana/scenes@npm:6.30.0" +"@grafana/scenes@npm:6.30.1, @grafana/scenes@npm:^6.30.0": + version: 6.30.1 + resolution: "@grafana/scenes@npm:6.30.1" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3628,7 +3628,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/b1036a1d8c531b3e197c3de276c4fe4a7092fcd27e1fd350ff6530cabb8eb5436a6184840ef6a4e6747510326e58c9a73d54b0c36f8ee7d20f8fd6eaccea8d75 + checksum: 10/6c50a31330ecb674de6664d6e119e54dee7ecee50925a71870c0a08562e4b3d5614e56e34008d6710e65ef03a8220ba2e9d811804b8a001392de46ef745a2e75 languageName: node linkType: hard From 38672c7936585f3764890967e3681dcb26df43a8 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 20 Aug 2025 13:04:00 +0200 Subject: [PATCH 12/53] Alerting: Set specific backend type in remote_writer_writes_total metric for grafanacloud-prom datasource (#109516) --- .../ngalert/writer/datasourcewriter.go | 17 +++- .../ngalert/writer/datasourcewriter_test.go | 95 +++++++++++++++---- pkg/services/ngalert/writer/prom.go | 30 +++--- 3 files changed, 112 insertions(+), 30 deletions(-) diff --git a/pkg/services/ngalert/writer/datasourcewriter.go b/pkg/services/ngalert/writer/datasourcewriter.go index fc9a3087c7b..19af0db0a5b 100644 --- a/pkg/services/ngalert/writer/datasourcewriter.go +++ b/pkg/services/ngalert/writer/datasourcewriter.go @@ -32,6 +32,13 @@ const ( cacheCleanupInterval = 10 * time.Minute ) +type backendType string + +const ( + grafanaCloudPromType backendType = "grafanacloud-prom" + prometheusType backendType = "prometheus" +) + type DatasourceWriterConfig struct { // Timeout is the maximum time to wait for a remote write to succeed. Timeout time.Duration @@ -203,6 +210,13 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st headers.Add(k, v) } + var backend backendType + if dsUID == string(grafanaCloudPromType) { + backend = grafanaCloudPromType + } else { + backend = prometheusType + } + cfg := PrometheusWriterConfig{ URL: u.String(), HTTPOptions: httpclient.Options{ @@ -212,7 +226,8 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st Header: headers, ProxyOptions: ho.ProxyOptions, }, - Timeout: w.cfg.Timeout, + Timeout: w.cfg.Timeout, + BackendType: backend, } if err != nil { return nil, err diff --git a/pkg/services/ngalert/writer/datasourcewriter_test.go b/pkg/services/ngalert/writer/datasourcewriter_test.go index 09fc1ac6455..f06d2d95de4 100644 --- a/pkg/services/ngalert/writer/datasourcewriter_test.go +++ b/pkg/services/ngalert/writer/datasourcewriter_test.go @@ -2,7 +2,9 @@ package writer import ( "context" + "fmt" "net/http" + "strings" "testing" "time" @@ -10,6 +12,7 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -127,7 +130,7 @@ func TestDatasourceWriter(t *testing.T) { series := []map[string]string{{"foo": "1"}, {"foo": "2"}, {"foo": "3"}, {"foo": "4"}} frames := frameGenFromLabels(t, data.FrameTypeNumericWide, series) - datasources := setupDataSources(t) + testDS := setupDataSources(t) cfg := DatasourceWriterConfig{ Timeout: time.Second * 5, @@ -136,26 +139,26 @@ func TestDatasourceWriter(t *testing.T) { met := metrics.NewRemoteWriterMetrics(prometheus.NewRegistry()) pluginContextProvider := &mockPluginContextProvider{} - writer := NewDatasourceWriter(cfg, datasources, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) + writer := NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) t.Run("when writing a prometheus datasource then the request is made to the expected endpoint", func(t *testing.T) { - datasources.Reset() + testDS.Reset() err := writer.WriteDatasource(context.Background(), "prom-1", "metric", time.Now(), frames, 1, map[string]string{}) require.NoError(t, err) - assert.Equal(t, 1, datasources.prom1.RequestsCount) - assert.Equal(t, 0, datasources.prom2.RequestsCount) + assert.Equal(t, 1, testDS.prom1.RequestsCount) + assert.Equal(t, 0, testDS.prom2.RequestsCount) err = writer.WriteDatasource(context.Background(), "prom-2", "metric", time.Now(), frames, 1, map[string]string{}) require.NoError(t, err) - assert.Equal(t, 1, datasources.prom1.RequestsCount) - assert.Equal(t, 1, datasources.prom2.RequestsCount) + assert.Equal(t, 1, testDS.prom1.RequestsCount) + assert.Equal(t, 1, testDS.prom2.RequestsCount) }) t.Run("when writing an unknown datasource then an error is returned", func(t *testing.T) { - datasources.Reset() + testDS.Reset() err := writer.WriteDatasource(context.Background(), "prom-unknown", "metric", time.Now(), frames, 1, map[string]string{}) require.Error(t, err) @@ -163,7 +166,7 @@ func TestDatasourceWriter(t *testing.T) { }) t.Run("when writing a non-prometheus datasource then an error is returned", func(t *testing.T) { - datasources.Reset() + testDS.Reset() err := writer.WriteDatasource(context.Background(), "loki-1", "metric", time.Now(), frames, 1, map[string]string{}) require.Error(t, err) @@ -171,14 +174,14 @@ func TestDatasourceWriter(t *testing.T) { }) t.Run("when writing with an empty datasource uid then the default is written", func(t *testing.T) { - datasources.Reset() + testDS.Reset() err := writer.WriteDatasource(context.Background(), "", "metric", time.Now(), frames, 1, map[string]string{}) require.NoError(t, err) }) t.Run("when custom headers are configured, they are passed to the request", func(t *testing.T) { - datasources.Reset() + testDS.Reset() header1 := "X-Custom-Header" header2 := "X-Another-Header" @@ -192,17 +195,17 @@ func TestDatasourceWriter(t *testing.T) { DefaultDatasourceUID: "prom-2", CustomHeaders: headers, } - writer = NewDatasourceWriter(cfg, datasources, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) + writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) err := writer.WriteDatasource(context.Background(), "prom-1", "metric", time.Now(), frames, 1, map[string]string{}) require.NoError(t, err) - assert.Equal(t, headers[header1], datasources.prom1.LastHeaders.Get(header1)) - assert.Equal(t, headers[header2], datasources.prom1.LastHeaders.Get(header2)) + assert.Equal(t, headers[header1], testDS.prom1.LastHeaders.Get(header1)) + assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2)) }) t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) { - datasources.Reset() + testDS.Reset() mockProvider := newMockHTTPClientProvider() @@ -212,7 +215,7 @@ func TestDatasourceWriter(t *testing.T) { } met := metrics.NewRemoteWriterMetrics(prometheus.NewRegistry()) - writer := NewDatasourceWriter(cfg, datasources, mockProvider, &mockPluginContextProvider{}, clock.New(), log.New("test"), met) + writer := NewDatasourceWriter(cfg, testDS, mockProvider, &mockPluginContextProvider{}, clock.New(), log.New("test"), met) err := writer.WriteDatasource(context.Background(), "prom-3", "metric", time.Now(), frames, 1, map[string]string{}) require.NoError(t, err) @@ -228,7 +231,7 @@ func TestDatasourceWriter(t *testing.T) { }) t.Run("when PDC is disabled proxy options are not set", func(t *testing.T) { - datasources.Reset() + testDS.Reset() mockProvider := newMockHTTPClientProvider() @@ -238,7 +241,7 @@ func TestDatasourceWriter(t *testing.T) { } met := metrics.NewRemoteWriterMetrics(prometheus.NewRegistry()) - writer := NewDatasourceWriter(cfg, datasources, mockProvider, &mockPluginContextProvider{}, clock.New(), log.New("test"), met) + writer := NewDatasourceWriter(cfg, testDS, mockProvider, &mockPluginContextProvider{}, clock.New(), log.New("test"), met) err := writer.WriteDatasource(context.Background(), "prom-1", "metric", time.Now(), frames, 1, map[string]string{}) require.NoError(t, err) @@ -247,6 +250,62 @@ func TestDatasourceWriter(t *testing.T) { require.NotNil(t, mockProvider.lastOptions) require.Nil(t, mockProvider.lastOptions.ProxyOptions) }) + + t.Run("datasource uses correct backend type in metrics", func(t *testing.T) { + testCases := []struct { + name string + datasourceUID string + expectedBackendType string + }{ + { + name: "grafanacloud-prom uses special backend type", + datasourceUID: string(grafanaCloudPromType), + expectedBackendType: string(grafanaCloudPromType), + }, + { + name: "prometheus uses default backend type", + datasourceUID: "prom-1", + expectedBackendType: "prometheus", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + testDS.Reset() + + if tc.datasourceUID == string(grafanaCloudPromType) { + gcProm, _ := testDS.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ + Name: string(grafanaCloudPromType), + UID: string(grafanaCloudPromType), + Type: datasources.DS_PROMETHEUS, + JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)), + }) + gcProm.URL = testDS.prom1.srv.URL + } + + cfg := DatasourceWriterConfig{ + Timeout: time.Second * 5, + DefaultDatasourceUID: "prom-2", + } + + reg := prometheus.NewRegistry() + met := metrics.NewRemoteWriterMetrics(reg) + writer := NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) + + err := writer.WriteDatasource(context.Background(), tc.datasourceUID, "metric", time.Now(), frames, 1, map[string]string{}) + require.NoError(t, err) + + expectedMetric := fmt.Sprintf(` + # HELP grafana_alerting_remote_writer_writes_total The total number of remote writes attempted. + # TYPE grafana_alerting_remote_writer_writes_total counter + grafana_alerting_remote_writer_writes_total{backend="%s",org="1",status_code="200"} 1 + `, tc.expectedBackendType) + require.NoError(t, testutil.CollectAndCompare(met.WritesTotal, + strings.NewReader(expectedMetric), + "grafana_alerting_remote_writer_writes_total")) + }) + } + }) } func TestDatasourceWriterGetRemoteWriteURL(t *testing.T) { diff --git a/pkg/services/ngalert/writer/prom.go b/pkg/services/ngalert/writer/prom.go index 64fb525ff44..7ade2fc1e09 100644 --- a/pkg/services/ngalert/writer/prom.go +++ b/pkg/services/ngalert/writer/prom.go @@ -20,8 +20,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" ) -const backendType = "prometheus" - const ( // Network error strings networkErrDialTCP = "dial tcp" @@ -236,16 +234,18 @@ type HttpClientProvider interface { } type PrometheusWriter struct { - client promremote.Client - clock clock.Clock - logger log.Logger - metrics *metrics.RemoteWriter + client promremote.Client + clock clock.Clock + logger log.Logger + metrics *metrics.RemoteWriter + backendType backendType } type PrometheusWriterConfig struct { URL string HTTPOptions httpclient.Options Timeout time.Duration + BackendType backendType } func NewPrometheusWriter( @@ -272,11 +272,19 @@ func NewPrometheusWriter( return nil, err } + var backend backendType + if cfg.BackendType != "" { + backend = cfg.BackendType + } else { + backend = prometheusType + } + return &PrometheusWriter{ - client: client, - clock: clock, - logger: l, - metrics: metrics, + client: client, + clock: clock, + logger: l, + metrics: metrics, + backendType: backend, }, nil } @@ -295,7 +303,7 @@ func (w PrometheusWriter) WriteDatasource(ctx context.Context, dsUID string, nam // Write writes the given frames to the Prometheus remote write endpoint. func (w PrometheusWriter) Write(ctx context.Context, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error { l := w.logger.FromContext(ctx) - lvs := []string{fmt.Sprint(orgID), backendType} + lvs := []string{fmt.Sprint(orgID), string(w.backendType)} points, err := PointsFromFrames(name, t, frames, extraLabels) if err != nil { From 71fd156eda216d77bc92ee5a01c736e23f91bb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 20 Aug 2025 13:18:59 +0200 Subject: [PATCH 13/53] Plugins: adds plugin version to log output (#109845) --- .../datasources/components/DataSourcePluginSettings.tsx | 6 +++++- public/app/features/plugins/extensions/utils.test.tsx | 4 ++-- public/app/features/plugins/extensions/utils.tsx | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/public/app/features/datasources/components/DataSourcePluginSettings.tsx b/public/app/features/datasources/components/DataSourcePluginSettings.tsx index 78062064bfc..f920837275b 100644 --- a/public/app/features/datasources/components/DataSourcePluginSettings.tsx +++ b/public/app/features/datasources/components/DataSourcePluginSettings.tsx @@ -34,7 +34,11 @@ export class DataSourcePluginSettings extends PureComponent {
{plugin.components.ConfigEditor && createElement(plugin.components.ConfigEditor, { - options: writableProxy(dataSource, { source: 'datasource', pluginId: plugin.meta?.id }), + options: writableProxy(dataSource, { + source: 'datasource', + pluginId: plugin.meta?.id, + pluginVersion: plugin.meta?.info?.version, + }), onOptionsChange: this.onModelChanged, })}
diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index 33da958ae89..6f8837f206d 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -903,7 +903,7 @@ describe('Plugin Extensions / Utils', () => { // Logs a warning expect(log.error).toHaveBeenCalledTimes(1); expect(log.error).toHaveBeenCalledWith( - `Attempted to mutate object property "c" from extension with id grafana-worldmap-panel and version unknown`, + `Attempted to mutate object property "c" from extension with id grafana-worldmap-panel and version 1.0.0`, { stack: expect.any(String), } @@ -931,7 +931,7 @@ describe('Plugin Extensions / Utils', () => { // Logs a warning expect(log.warning).toHaveBeenCalledTimes(1); expect(log.warning).toHaveBeenCalledWith( - `Attempted to mutate object property "c" from extension with id grafana-worldmap-panel and version unknown`, + `Attempted to mutate object property "c" from extension with id grafana-worldmap-panel and version 1.0.0`, { stack: expect.any(String), } diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index f083059cece..dc195f08888 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -98,7 +98,9 @@ export const wrapWithPluginContext = ({ return ( - + ); From 5ec1b4198cffb825e49b9ed8399880f54680dbf1 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Wed, 20 Aug 2025 06:35:56 -0600 Subject: [PATCH 14/53] Dashboard Schema V2: Handle a case when fieldConfig is undefined (#109881) handle a case when fieldConfig is undefined --- .../transformSceneToSaveModelSchemaV2.ts | 80 +++++++++++-------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index d13905c0a58..8a330cb744c 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -41,8 +41,8 @@ import { LibraryPanelKind, Element, DashboardCursorSync, - FieldConfig, FieldColor, + defaultFieldConfig, defaultDataQueryKind, } from '../../../../../packages/grafana-schema/src/schema/dashboard/v2'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; @@ -175,40 +175,7 @@ export function vizPanelToSchemaV2( return elementSpec; } - // Handle type conversion for color mode - const rawColor = vizPanel.state.fieldConfig.defaults.color; - let color: FieldColor | undefined; - - if (rawColor) { - const convertedMode = colorIdEnumToColorIdV2(rawColor.mode); - - if (convertedMode) { - color = { - ...rawColor, - mode: convertedMode, - }; - } - } - - // Remove null from the defaults because schema V2 doesn't support null for these fields - const decimals = vizPanel.state.fieldConfig.defaults.decimals ?? undefined; - const min = vizPanel.state.fieldConfig.defaults.min ?? undefined; - const max = vizPanel.state.fieldConfig.defaults.max ?? undefined; - - const defaults: FieldConfig = Object.fromEntries( - Object.entries({ - ...vizPanel.state.fieldConfig.defaults, - decimals, - min, - max, - color, - }).filter(([_, value]) => { - if (Array.isArray(value)) { - return value.length > 0; - } - return value !== undefined; - }) - ); + const defaults = handleFieldConfigDefaultsConversion(vizPanel); const vizFieldConfig: FieldConfigSource = { ...vizPanel.state.fieldConfig, @@ -245,6 +212,49 @@ export function vizPanelToSchemaV2( return elementSpec; } +function handleFieldConfigDefaultsConversion(vizPanel: VizPanel) { + if (!vizPanel.state.fieldConfig || !vizPanel.state.fieldConfig.defaults) { + return defaultFieldConfig(); + } + + // Handle type conversion for color mode + const rawColor = vizPanel.state.fieldConfig.defaults.color; + let color: FieldColor | undefined; + + if (rawColor) { + const convertedMode = colorIdEnumToColorIdV2(rawColor.mode); + + if (convertedMode) { + color = { + ...rawColor, + mode: convertedMode, + }; + } + } + + // Remove null from the defaults because schema V2 doesn't support null for these fields + const decimals = vizPanel.state.fieldConfig.defaults.decimals ?? undefined; + const min = vizPanel.state.fieldConfig.defaults.min ?? undefined; + const max = vizPanel.state.fieldConfig.defaults.max ?? undefined; + + const defaults = Object.fromEntries( + Object.entries({ + ...vizPanel.state.fieldConfig.defaults, + decimals, + min, + max, + color, + }).filter(([_, value]) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== undefined; + }) + ); + + return defaults; +} + function getPanelLinks(panel: VizPanel): DataLink[] { const vizLinks = dashboardSceneGraph.getPanelLinks(panel); if (vizLinks) { From af1b6dd1718ff361bb4cc286002d712043da2ca3 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 20 Aug 2025 13:54:51 +0100 Subject: [PATCH 15/53] Chore: Readd api client verification and update clients (#109847) * add api client verification to lint step * separate steps, clearer error messages * make same change to non-enterprise version * double-quote * ignore conf * update generated apis * add names for steps * fix comment --- .github/workflows/frontend-lint.yml | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml index fb0362bb580..96e38b5c5c6 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/frontend-lint.yml @@ -139,3 +139,70 @@ jobs: cache-dependency-path: 'yarn.lock' - run: yarn install --immutable --check-cache - run: yarn run betterer:ci + lint-frontend-api-clients: + permissions: + contents: read + id-token: write + # Run this workflow only for PRs from forks; if it gets merged into `main` or `release-*`, + # the `lint-frontend-api-clients-enterprise` workflow will run instead + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + name: Verify API clients + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + cache-dependency-path: 'yarn.lock' + - run: yarn install --immutable --check-cache + - name: Generate API clients + run: | + extract_error_message='ERROR! API client generation failed!' + yarn generate-apis || (echo "${extract_error_message}" && false) + - name: Verify generated clients + run: | + uncommited_error_message="ERROR! API client generation has not been committed. Please run 'yarn generate-apis', commit the changes and push again." + file_diff="$(git diff ':!conf')" + if [ -n "$file_diff" ]; then + echo "$file_diff" + echo "${uncommited_error_message}" + exit 1 + fi + lint-frontend-api-clients-enterprise: + permissions: + contents: read + id-token: write + # Run this workflow for non-PR events (like pushes to `main` or `release-*`) OR for internal PRs (PRs not from forks) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + name: Verify API clients (enterprise) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + cache-dependency-path: 'yarn.lock' + - name: Setup Enterprise + uses: ./.github/actions/setup-enterprise + with: + github-app-name: 'grafana-ci-bot' + - run: yarn install --immutable --check-cache + - name: Generate API clients + run: | + extract_error_message='ERROR! API client generation failed!' + yarn generate-apis || (echo "${extract_error_message}" && false) + - name: Verify generated clients + run: | + uncommited_error_message="ERROR! API client generation has not been committed. Please run 'yarn generate-apis', commit the changes and push again." + file_diff="$(git diff ':!conf')" + if [ -n "$file_diff" ]; then + echo "$file_diff" + echo "${uncommited_error_message}" + exit 1 + fi From c8d9b5b207eaa9d5d2061e579313b6a909ef552c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 20 Aug 2025 15:43:45 +0100 Subject: [PATCH 16/53] Chore: regenerate api clients (#109919) regenerate apis --- .../provisioning/v0alpha1/endpoints.gen.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts index 33ee7549eb7..b8295bc8802 100644 --- a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts @@ -658,6 +658,7 @@ export type CreateRepositoryTestApiArg = { /** 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; metadata?: any; + secure?: any; spec?: any; status?: any; }; @@ -909,6 +910,37 @@ export type JobList = { kind?: string; metadata?: ListMeta; }; +export type InlineSecureValue = + | { + /** Create a secure value -- this is only used for POST/PUT */ + create?: string; + /** Name in the secret service (reference) */ + name: string; + /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ + remove?: boolean; + } + | { + /** Create a secure value -- this is only used for POST/PUT */ + create: string; + /** Name in the secret service (reference) */ + name?: string; + /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ + remove?: boolean; + } + | { + /** Create a secure value -- this is only used for POST/PUT */ + create?: string; + /** Name in the secret service (reference) */ + name?: string; + /** Remove this value from the secure value map Values owned by this resource will be deleted if necessary */ + remove: boolean; + }; +export type SecureValues = { + /** Token used to connect the configured repository */ + token?: InlineSecureValue; + /** Some webhooks (github) require a secret key value */ + webhookSecret?: InlineSecureValue; +}; export type BitbucketRepositoryConfig = { /** The branch to use in the repository. */ branch: string; @@ -1079,6 +1111,7 @@ export type Repository = { /** 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; metadata?: ObjectMeta; + secure?: SecureValues; spec?: RepositorySpec; status?: RepositoryStatus; }; From 6386e8a7347f9fc00a6c93b0d6f1907e8a9e45bd Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 20 Aug 2025 16:48:26 +0100 Subject: [PATCH 17/53] Chore: Remove duplicated `various-suite` cypress tests (#109737) * remove duplicated various-suite cypress tests and update CODEOWNERS for playwright versions * assign missing CODEOWNERS --- .github/CODEOWNERS | 46 +++- .../various-suite/select-focus.spec.ts | 34 --- e2e/various-suite/bar-gauge.spec.ts | 20 -- e2e/various-suite/bookmarks.spec.ts | 75 ------ e2e/various-suite/exemplars.spec.ts | 76 ------ e2e/various-suite/explore.spec.ts | 22 -- e2e/various-suite/filter-annotations.spec.ts | 73 ------ .../frontend-sandbox-app.spec.ts | 71 ------ e2e/various-suite/gauge.spec.ts | 18 -- e2e/various-suite/graph-auto-migrate.spec.ts | 61 ----- e2e/various-suite/inspect-drawer.spec.ts | 125 ---------- e2e/various-suite/keybinds.spec.ts | 64 ----- e2e/various-suite/loki-query-builder.spec.ts | 106 --------- .../loki-table-explore-to-dash.spec.ts | 225 ------------------ e2e/various-suite/navigation.spec.ts | 60 ----- e2e/various-suite/pie-chart.spec.ts | 18 -- .../prometheus-annotations.spec.ts | 74 ------ e2e/various-suite/query-editor.spec.ts | 32 --- e2e/various-suite/return-to-previous.spec.ts | 71 ------ e2e/various-suite/select-focus.spec.ts | 34 --- e2e/various-suite/solo-route.spec.ts | 44 ---- e2e/various-suite/verify-i18n.spec.ts | 64 ----- .../visualization-suggestions.spec.ts | 27 --- 23 files changed, 43 insertions(+), 1397 deletions(-) delete mode 100644 e2e/old-arch/various-suite/select-focus.spec.ts delete mode 100644 e2e/various-suite/bar-gauge.spec.ts delete mode 100644 e2e/various-suite/bookmarks.spec.ts delete mode 100644 e2e/various-suite/exemplars.spec.ts delete mode 100644 e2e/various-suite/explore.spec.ts delete mode 100644 e2e/various-suite/filter-annotations.spec.ts delete mode 100644 e2e/various-suite/frontend-sandbox-app.spec.ts delete mode 100644 e2e/various-suite/gauge.spec.ts delete mode 100644 e2e/various-suite/graph-auto-migrate.spec.ts delete mode 100644 e2e/various-suite/inspect-drawer.spec.ts delete mode 100644 e2e/various-suite/keybinds.spec.ts delete mode 100644 e2e/various-suite/loki-query-builder.spec.ts delete mode 100644 e2e/various-suite/loki-table-explore-to-dash.spec.ts delete mode 100644 e2e/various-suite/navigation.spec.ts delete mode 100644 e2e/various-suite/pie-chart.spec.ts delete mode 100644 e2e/various-suite/prometheus-annotations.spec.ts delete mode 100644 e2e/various-suite/query-editor.spec.ts delete mode 100644 e2e/various-suite/return-to-previous.spec.ts delete mode 100644 e2e/various-suite/select-focus.spec.ts delete mode 100644 e2e/various-suite/solo-route.spec.ts delete mode 100644 e2e/various-suite/verify-i18n.spec.ts delete mode 100644 e2e/various-suite/visualization-suggestions.spec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4cae4112b3..52c4a32d02c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -411,9 +411,14 @@ /public/locales/i18next-parser-enterprise.config.cjs @grafana/grafana-frontend-platform /public/app/core/internationalization/ @grafana/grafana-frontend-platform /e2e/ @grafana/grafana-frontend-platform -/e2e-playwright/ @grafana/grafana-frontend-platform /e2e-playwright/cloud-plugins-suite/ @grafana/partner-datasources -/e2e-playwright/dashboard-new-layouts @grafana/dashboards-squad +/e2e-playwright/dashboard-new-layouts/ @grafana/dashboards-squad +/e2e-playwright/dashboards-search-suite/ @grafana/dashboards-squad +/e2e-playwright/dashboards/DashboardLiveTest.json @grafana/dashboards-squad +/e2e-playwright/dashboards/DataLinkWithoutSlugTest.json @grafana/dashboards-squad +/e2e-playwright/dashboards/PanelSandboxDashboard.json @grafana/plugins-platform-frontend +/e2e-playwright/dashboards/TestDashboard.json @grafana/dashboards-squad @grafana/grafana-search-navigate-organise +/e2e-playwright/dashboards/TestV2Dashboard.json @grafana/dashboards-squad /e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @grafana/grafana-search-navigate-organise /e2e-playwright/dashboards-suite/dashboard-browse.spec.ts @grafana/grafana-search-navigate-organise /e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @grafana/sharing-squad @@ -447,6 +452,9 @@ /e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @grafana/dashboards-squad /e2e-playwright/dashboards-suite/textbox-variables.spec.ts @grafana/dashboards-squad /e2e-playwright/dashboards-suite/utils/makeDashboard.ts @grafana/grafana-search-navigate-organise +/e2e-playwright/fixtures/exemplars-query-response.json @grafana/observability-traces-and-profiling +/e2e-playwright/fixtures/long-trace-response.json @grafana/observability-traces-and-profiling +/e2e-playwright/fixtures/tempo-response.json @grafana/oss-big-tent /e2e-playwright/panels-suite/dashlist.spec.ts @grafana/grafana-search-navigate-organise /e2e-playwright/panels-suite/datagrid-data-change.spec.ts @grafana/dataviz-squad /e2e-playwright/panels-suite/datagrid-editing-features.spec.ts @grafana/dataviz-squad @@ -458,10 +466,42 @@ /e2e-playwright/panels-suite/panelEdit_queries.spec.ts @grafana/dashboards-squad /e2e-playwright/panels-suite/panelEdit_transforms.spec.ts @grafana/datapro /e2e-playwright/panels-suite/table-kitchenSink.spec.ts @grafana/dataviz-squad +/e2e-playwright/panels-suite/table-markdown.spec.ts @grafana/dataviz-squad /e2e-playwright/panels-suite/table-sparkline.spec.ts @grafana/dataviz-squad /e2e-playwright/plugin-e2e/ @grafana/oss-big-tent @grafana/partner-datasources /e2e-playwright/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend -/e2e-playwright/test-plugins/grafana-extensionstest-app/ @grafana/plugins-platform-frontend +/e2e-playwright/smoke-tests-suite/ @grafana/grafana-frontend-platform +/e2e-playwright/start-server @grafana/grafana-frontend-platform +/e2e-playwright/storybook/ @grafana/grafana-frontend-platform +/e2e-playwright/test-plugins/ @grafana/plugins-platform-frontend +/e2e-playwright/unauthenticated/login.spec.ts @grafana/grafana-frontend-platform +/e2e-playwright/utils/ @grafana/grafana-frontend-platform +/e2e-playwright/various-suite/bar-gauge.spec.ts @grafana/dataviz-squad +/e2e-playwright/various-suite/bookmarks.spec.ts @grafana/grafana-search-navigate-organise +/e2e-playwright/various-suite/exemplars.spec.ts @grafana/observability-traces-and-profiling +/e2e-playwright/various-suite/explore.spec.ts @grafana/observability-traces-and-profiling +/e2e-playwright/various-suite/filter-annotations.spec.ts @grafana/dashboards-squad +/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend +/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend +/e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad +/e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad +/e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad +/e2e-playwright/various-suite/keybinds.spec.ts @grafana/grafana-frontend-platform +/e2e-playwright/various-suite/loki-query-builder.spec.ts @grafana/oss-big-tent +/e2e-playwright/various-suite/loki-table-explore-to-dash.spec.ts @grafana/oss-big-tent +/e2e-playwright/various-suite/migrate-to-cloud.spec.ts @grafana/grafana-operator-experience-squad +/e2e-playwright/various-suite/navigation.spec.ts @grafana/grafana-search-navigate-organise +/e2e-playwright/various-suite/pie-chart.spec.ts @grafana/dataviz-squad +/e2e-playwright/various-suite/prometheus-annotations.spec.ts @grafana/oss-big-tent +/e2e-playwright/various-suite/prometheus-config.spec.ts @grafana/oss-big-tent +/e2e-playwright/various-suite/prometheus-editor.spec.ts @grafana/oss-big-tent +/e2e-playwright/various-suite/prometheus-variable-editor.spec.ts @grafana/oss-big-tent +/e2e-playwright/various-suite/query-editor.spec.ts @grafana/observability-traces-and-profiling +/e2e-playwright/various-suite/return-to-previous.spec.ts @grafana/grafana-search-navigate-organise +/e2e-playwright/various-suite/solo-route.spec.ts @grafana/dashboards-squad +/e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling +/e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform +/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dashboards-squad # Packages /packages/README.md @grafana/grafana-frontend-platform diff --git a/e2e/old-arch/various-suite/select-focus.spec.ts b/e2e/old-arch/various-suite/select-focus.spec.ts deleted file mode 100644 index 5ff66306b35..00000000000 --- a/e2e/old-arch/various-suite/select-focus.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { e2e } from '../utils'; - -describe('Select focus/unfocus tests', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it.skip('Tests select focus/unfocus scenarios', () => { - e2e.flows.openDashboard({ uid: '5SdHCadmz' }); - e2e.components.PageToolbar.item('Dashboard settings').click(); - - e2e.components.FolderPicker.containerV2() - .should('be.visible') - .within(() => { - cy.get('#dashboard-folder-input').should('be.visible').click(); - }); - - e2e.components.Select.option().should('be.visible').first().click(); - - e2e.components.FolderPicker.containerV2() - .should('be.visible') - .within(() => { - cy.get('#dashboard-folder-input').should('exist').should('have.focus'); - }); - - e2e.pages.Dashboard.Settings.General.title().click(); - - e2e.components.FolderPicker.containerV2() - .should('be.visible') - .within(() => { - cy.get('#dashboard-folder-input').should('exist').should('not.have.focus'); - }); - }); -}); diff --git a/e2e/various-suite/bar-gauge.spec.ts b/e2e/various-suite/bar-gauge.spec.ts deleted file mode 100644 index 7c28c48a146..00000000000 --- a/e2e/various-suite/bar-gauge.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { selectors } from '@grafana/e2e-selectors'; - -import { e2e } from '../utils'; - -describe('Bar Gauge Panel', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Bar Gauge rendering e2e tests', () => { - // open Panel Tests - Bar Gauge - e2e.flows.openDashboard({ uid: 'O6f11TZWk' }); - - cy.get( - `[data-viz-panel-key="panel-6"] [data-testid^="${selectors.components.Panels.Visualization.BarGauge.valueV2}"]` - ) - .should('have.css', 'color', 'rgb(242, 73, 92)') - .contains('100'); - }); -}); diff --git a/e2e/various-suite/bookmarks.spec.ts b/e2e/various-suite/bookmarks.spec.ts deleted file mode 100644 index 95781d1e6e2..00000000000 --- a/e2e/various-suite/bookmarks.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { e2e } from '../utils'; -import { fromBaseUrl } from '../utils/support/url'; - -describe('Pin nav items', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - afterEach(() => { - e2e.flows.setDefaultUserPreferences(); - }); - - it('should pin the selected menu item and add it as a Bookmarks menu item child', () => { - cy.visit(fromBaseUrl('/'), { - onBeforeLoad: (win) => { - win.localStorage.setItem('grafana.navigation.docked', 'true'); // Make sure the menu is docked - }, - }); - - e2e.components.NavMenu.Menu() - .should('be.visible') - .within(() => { - cy.get('ul[aria-label="Navigation"]').should('be.visible').as('navList'); - - // Check if the Bookmark section is visible - cy.get('@navList').children().eq(1).should('be.visible').as('bookmarksItem'); - cy.get('@bookmarksItem').should('contain.text', 'Bookmarks'); - - // Check if the Adminstration section is visible - cy.get('@navList').children().last().should('be.visible').as('adminItem'); - cy.get('@adminItem').should('contain.text', 'Administration'); - cy.get('@adminItem').within(() => { - cy.get('button[aria-label="Add to Bookmarks"]').should('exist').click({ force: true }); - }); - - // Check if the Administration menu item is visible in the Bookmarks section - cy.get('@bookmarksItem').within(() => { - // Expand the Bookmarks section - cy.get('button[aria-label="Expand section: Bookmarks"]').should('exist').click({ force: true }); - cy.get('a').should('contain.text', 'Administration').should('be.visible'); - }); - }); - }); - - it('should unpin the item and remove it from the Bookmarks section', () => { - // Set Administration as a pinned item and reload the page - e2e.flows.setUserPreferences({ navbar: { bookmarkUrls: ['/admin'] } }); - - cy.visit(fromBaseUrl('/'), { - onBeforeLoad: (win) => { - win.localStorage.setItem('grafana.navigation.docked', 'true'); // Make sure the menu is docked - }, - }); - - e2e.components.NavMenu.Menu() - .should('be.visible') - .within(() => { - cy.get('ul[aria-label="Navigation"]').should('be.visible').as('navList'); - - // Check if the Bookmark section is visible - cy.get('@navList').children().eq(1).should('be.visible').as('bookmarksItem'); - cy.get('@bookmarksItem').should('contain.text', 'Bookmarks'); - cy.get('@bookmarksItem').within(() => { - // Expand the Bookmarks section - cy.get('button[aria-label="Expand section: Bookmarks"]').should('exist').click({ force: true }); - cy.get('a').should('contain.text', 'Administration').should('be.visible'); - cy.get('button[aria-label="Remove from Bookmarks"]').should('exist').click({ force: true }); - }); - - cy.get('@bookmarksItem', { timeout: 60000 }).within(() => { - cy.get('a').should('have.length', 1).should('not.contain.text', 'Administration'); - }); - }); - }); -}); diff --git a/e2e/various-suite/exemplars.spec.ts b/e2e/various-suite/exemplars.spec.ts deleted file mode 100644 index 675b83039ba..00000000000 --- a/e2e/various-suite/exemplars.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { e2e } from '../utils'; -import { waitForMonacoToLoad } from '../utils/support/monaco'; - -const dataSourceName = 'PromExemplar'; -const addDataSource = () => { - e2e.flows.addDataSource({ - type: 'Prometheus', - expectedAlertMessage: 'Prometheus', - name: dataSourceName, - form: () => { - e2e.components.DataSource.Prometheus.configPage.exemplarsAddButton().click(); - e2e.components.DataSource.Prometheus.configPage.internalLinkSwitch().check({ force: true }); - e2e.components.DataSource.Prometheus.configPage.connectionSettings().type('http://prom-url:9090'); - cy.get('[data-testid="data-testid Data source picker select container"]').click(); - - cy.contains('gdev-tempo').scrollIntoView().should('be.visible').click(); - }, - }); -}; -// Skipping due to race conditions with same old arch test e2e/various-suite/exemplars.spec.ts -describe.skip('Exemplars', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - - cy.request({ - url: `${Cypress.env('BASE_URL')}/api/datasources/name/${dataSourceName}`, - failOnStatusCode: false, - }).then((response) => { - if (response.isOkStatusCode) { - return; - } - addDataSource(); - }); - }); - - it('should be able to navigate to configured data source', () => { - cy.intercept( - { - pathname: '/api/ds/query', - }, - (req) => { - const datasourceType = req.body.queries[0].datasource.type; - if (datasourceType === 'prometheus') { - req.reply({ fixture: 'exemplars-query-response.json' }); - } else if (datasourceType === 'tempo') { - req.reply({ fixture: 'tempo-response.json' }); - } else { - req.reply({}); - } - } - ); - - e2e.pages.Explore.visit(); - - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(dataSourceName).scrollIntoView().should('be.visible').click(); - - // Switch to code editor - e2e.components.RadioButton.container().filter(':contains("Code")').click(); - - // Wait for lazy loading Monaco - waitForMonacoToLoad(); - - e2e.components.TimePicker.openButton().click(); - e2e.components.TimePicker.fromField().clear().type('2021-07-10 17:10:00'); - e2e.components.TimePicker.toField().clear().type('2021-07-10 17:30:00'); - e2e.components.TimePicker.applyTimeRange().click(); - e2e.components.QueryField.container().should('be.visible').type('exemplar-query_bucket{shift}{enter}'); - - cy.get(`[data-testid="time-series-zoom-to-data"]`).click(); - - e2e.components.DataSource.Prometheus.exemplarMarker().first().trigger('mousemove', { force: true }); - cy.contains('Query with gdev-tempo').click(); - e2e.components.TraceViewer.spanBar().should('have.length', 11); - }); -}); diff --git a/e2e/various-suite/explore.spec.ts b/e2e/various-suite/explore.spec.ts deleted file mode 100644 index 69d74c29e98..00000000000 --- a/e2e/various-suite/explore.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { e2e } from '../utils'; - -describe('Explore', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Basic path through Explore.', () => { - e2e.pages.Explore.visit(); - e2e.pages.Explore.General.container().should('have.length', 1); - e2e.components.RefreshPicker.runButtonV2().should('have.length', 1); - - e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() - .scrollIntoView() - .should('be.visible') - .within(() => { - cy.get('input[id*="test-data-scenario-select-"]').should('be.visible').click(); - }); - - cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); - }); -}); diff --git a/e2e/various-suite/filter-annotations.spec.ts b/e2e/various-suite/filter-annotations.spec.ts deleted file mode 100644 index 35319ee1beb..00000000000 --- a/e2e/various-suite/filter-annotations.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { e2e } from '../utils'; -const DASHBOARD_ID = 'ed155665'; - -// Skipping due to race conditions with same old arch test e2e/various-suite/filter-annotations.spec.ts -describe.skip('Annotations filtering', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests switching filter type updates the UI accordingly', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID }); - - e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click(); - e2e.components.NavToolbar.editDashboard.settingsButton().should('be.visible').click(); - - e2e.components.Tab.title('Annotations').click(); - cy.contains('New query').click(); - e2e.pages.Dashboard.Settings.Annotations.Settings.name().clear().type('Red - Panel two'); - - e2e.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel() - .should('be.visible') - .within(() => { - // All panels - e2e.components.Annotations.annotationsTypeInput().find('input').type('All panels{enter}', { force: true }); - e2e.components.Annotations.annotationsChoosePanelInput().should('not.exist'); - - // All panels except - e2e.components.Annotations.annotationsTypeInput() - .find('input') - .type('All panels except{enter}', { force: true }); - e2e.components.Annotations.annotationsChoosePanelInput().should('be.visible'); - - // Selected panels - e2e.components.Annotations.annotationsTypeInput().find('input').type('Selected panels{enter}', { force: true }); - e2e.components.Annotations.annotationsChoosePanelInput() - .should('be.visible') - .find('input') - .type('Panel two{enter}', { force: true }); - }); - - cy.get('body').click(); - - e2e.components.NavToolbar.editDashboard.backToDashboardButton().should('be.visible').click(); - - e2e.pages.Dashboard.Controls() - .should('be.visible') - .within(() => { - e2e.pages.Dashboard.SubMenu.submenuItemLabels('Red - Panel two') - .should('be.visible') - .parent() - .within((el) => { - cy.get('input') - .should('be.checked') - .uncheck({ force: true }) - .should('not.be.checked') - .check({ force: true }); - }); - - e2e.pages.Dashboard.SubMenu.submenuItemLabels('Red, only panel 1') - .should('be.visible') - .parent() - .within((el) => { - cy.get('input').should('be.checked'); - }); - }); - - e2e.components.Panels.Panel.title('Panel one') - .should('exist') - .within(() => { - e2e.pages.Dashboard.Annotations.marker().should('exist').should('have.length', 4); - }); - }); -}); diff --git a/e2e/various-suite/frontend-sandbox-app.spec.ts b/e2e/various-suite/frontend-sandbox-app.spec.ts deleted file mode 100644 index b3e17ece6a9..00000000000 --- a/e2e/various-suite/frontend-sandbox-app.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { e2e } from '../utils'; - -const APP_ID = 'sandbox-app-test'; - -describe('Datasource sandbox', () => { - before(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - cy.request({ - url: `${Cypress.env('BASE_URL')}/api/plugins/${APP_ID}/settings`, - method: 'POST', - body: { - enabled: true, - }, - }); - }); - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), true); - }); - - describe('App Page', () => { - describe('Sandbox disabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=0'); - }); - }); - - it('Loads the app page without the sandbox div wrapper', () => { - cy.visit(`/a/${APP_ID}`); - cy.wait(200); // wait to prevent false positives because cypress checks too fast - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); - cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); - }); - - it('Loads the app configuration without the sandbox div wrapper', () => { - cy.visit(`/plugins/${APP_ID}`); - cy.wait(200); // wait to prevent false positives because cypress checks too fast - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist'); - cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); - }); - }); - - describe('Sandbox enabled', () => { - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem('grafana.featureToggles', 'pluginsFrontendSandbox=1'); - }); - }); - - it('Loads the app page with the sandbox div wrapper', () => { - cy.visit(`/a/${APP_ID}`); - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); - cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist'); - }); - - it('Loads the app configuration with the sandbox div wrapper', () => { - cy.visit(`/plugins/${APP_ID}`); - cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist'); - cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist'); - }); - }); - }); - - afterEach(() => { - e2e.flows.revertAllChanges(); - }); - - after(() => { - cy.clearCookies(); - }); -}); diff --git a/e2e/various-suite/gauge.spec.ts b/e2e/various-suite/gauge.spec.ts deleted file mode 100644 index 64e4ce14b61..00000000000 --- a/e2e/various-suite/gauge.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { e2e } from '../utils'; - -describe('Gauge Panel', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Gauge rendering e2e tests', () => { - // open Panel Tests - Gauge - e2e.flows.openDashboard({ uid: '_5rDmaQiz' }); - - // check that gauges are rendered - cy.get('body').find(`.flot-base`).should('have.length', 16); - - // check that no panel errors exist - e2e.components.Panels.Panel.headerCornerInfo('error').should('not.exist'); - }); -}); diff --git a/e2e/various-suite/graph-auto-migrate.spec.ts b/e2e/various-suite/graph-auto-migrate.spec.ts deleted file mode 100644 index cddf07d0660..00000000000 --- a/e2e/various-suite/graph-auto-migrate.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { e2e } from '../utils'; - -const DASHBOARD_ID = 'XMjIZPmik'; -const DASHBOARD_NAME = 'Panel Tests - Graph Time Regions'; -const UPLOT_MAIN_DIV_SELECTOR = '[data-testid="uplot-main-div"]'; - -describe('Auto-migrate graph panel', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Graph panel is auto-migrated', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist'); - - e2e.flows.openDashboard({ uid: DASHBOARD_ID }); - - cy.get(UPLOT_MAIN_DIV_SELECTOR).should('exist'); - }); - - it('Annotation markers exist for time regions', () => { - e2e.flows.openDashboard({ uid: DASHBOARD_ID }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - cy.get(UPLOT_MAIN_DIV_SELECTOR).should('not.exist'); - - e2e.flows.openDashboard({ uid: DASHBOARD_ID }); - - e2e.components.Panels.Panel.title('Business Hours') - .should('exist') - .within(() => { - e2e.pages.Dashboard.Annotations.marker().should('exist'); - }); - - e2e.components.Panels.Panel.title("Sunday's 20-23") - .should('exist') - .within(() => { - e2e.pages.Dashboard.Annotations.marker().should('exist'); - }); - - e2e.components.Panels.Panel.title('Each day of week') - .should('exist') - .within(() => { - e2e.pages.Dashboard.Annotations.marker().should('exist'); - }); - - cy.scrollTo('bottom'); - - e2e.components.Panels.Panel.title('05:00') - .should('exist') - .within(() => { - e2e.pages.Dashboard.Annotations.marker().should('exist'); - }); - - e2e.components.Panels.Panel.title('From 22:00 to 00:30 (crossing midnight)') - .should('exist') - .within(() => { - e2e.pages.Dashboard.Annotations.marker().should('exist'); - }); - }); -}); diff --git a/e2e/various-suite/inspect-drawer.spec.ts b/e2e/various-suite/inspect-drawer.spec.ts deleted file mode 100644 index 48746dc7a9f..00000000000 --- a/e2e/various-suite/inspect-drawer.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { e2e } from '../utils'; - -const PANEL_UNDER_TEST = 'Value reducers 1'; - -describe('Inspect drawer tests', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Tests various Inspect Drawer scenarios', () => { - // @ts-ignore some typing issue - cy.on('uncaught:exception', (err) => { - if (err.stack?.indexOf("TypeError: Cannot read property 'getText' of null") !== -1) { - // On occasion monaco editor will not have the time to be properly unloaded when we change the tab - // and then the e2e test fails with the uncaught:exception: - // TypeError: Cannot read property 'getText' of null - // at Object.ai [as getFoldingRanges] (http://localhost:3001/public/build/monaco-json.worker.js:2:215257) - // at e.getFoldingRanges (http://localhost:3001/public/build/monaco-json.worker.js:2:221188) - // at e.fmr (http://localhost:3001/public/build/monaco-json.worker.js:2:116605) - // at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7414) - // at Object.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7018) - // at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:5038) - // at e.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:4606) - // at e.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7097) - // at Tt.self.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:117109) - - // return false to prevent the error from - // failing this test - return false; - } - - return true; - }); - - e2e.flows.openDashboard({ uid: 'wfTJJL5Wz' }); - - e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible'); - e2e.components.Panels.Panel.menu(PANEL_UNDER_TEST).click({ force: true }); // force click because menu is hidden and show on hover - e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true }); - e2e.components.Panels.Panel.menuItems('Data').click({ force: true }); - - expectDrawerTabsAndContent(); - - expectDrawerClose(); - - expectSubMenuScenario('Data'); - expectSubMenuScenario('Query'); - expectSubMenuScenario('Panel JSON', 'JSON'); - - e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); - - e2e.components.QueryTab.queryInspectorButton().should('be.visible').click(); - - e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`) - .should('be.visible') - .within(() => { - e2e.components.Tab.title('Query').should('be.visible'); - // query should be the active tab - e2e.components.Tab.active().should('have.text', 'Query'); - }); - - e2e.components.PanelInspector.Query.content().should('be.visible'); - }); -}); - -const expectDrawerTabsAndContent = () => { - e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`) - .should('be.visible') - .within(() => { - e2e.components.Tab.title('Data').should('be.visible'); - // data should be the active tab - e2e.components.Tab.active().within((li: JQuery) => { - expect(li.text()).equals('Data'); - }); - e2e.components.PanelInspector.Data.content().should('be.visible'); - e2e.components.PanelInspector.Stats.content().should('not.exist'); - e2e.components.PanelInspector.Json.content().should('not.exist'); - e2e.components.PanelInspector.Query.content().should('not.exist'); - - // other tabs should also be visible, click on each to see if we get any console errors - e2e.components.Tab.title('Stats').should('be.visible').click(); - e2e.components.PanelInspector.Stats.content().should('be.visible'); - e2e.components.PanelInspector.Data.content().should('not.exist'); - e2e.components.PanelInspector.Json.content().should('not.exist'); - e2e.components.PanelInspector.Query.content().should('not.exist'); - - e2e.components.Tab.title('JSON').should('be.visible').click(); - e2e.components.PanelInspector.Json.content().should('be.visible'); - e2e.components.PanelInspector.Data.content().should('not.exist'); - e2e.components.PanelInspector.Stats.content().should('not.exist'); - e2e.components.PanelInspector.Query.content().should('not.exist'); - - e2e.components.Tab.title('Query').should('be.visible').click(); - - e2e.components.PanelInspector.Query.content().should('be.visible'); - e2e.components.PanelInspector.Data.content().should('not.exist'); - e2e.components.PanelInspector.Stats.content().should('not.exist'); - e2e.components.PanelInspector.Json.content().should('not.exist'); - }); -}; - -const expectDrawerClose = () => { - // close using close button - e2e.components.Drawer.General.close().click(); - e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`).should('not.exist'); -}; - -const expectSubMenuScenario = (subMenu: string, tabTitle?: string) => { - tabTitle = tabTitle ?? subMenu; - // testing opening inspect drawer from sub menus under Inspect in header menu - e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible'); - e2e.components.Panels.Panel.menu(PANEL_UNDER_TEST).click({ force: true }); // force click because menu is hidden and show on hover - // sub menus are in the DOM but not visible and because there is no hover support in Cypress force click - // https://github.com/cypress-io/cypress-example-recipes/blob/master/examples/testing-dom__hover-hidden-elements/cypress/integration/hover-hidden-elements-spec.js - - // simulate hover on Inspector menu item to display sub menus - e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true }); - e2e.components.Panels.Panel.menuItems(subMenu).click({ force: true }); - - // data should be the default tab - e2e.components.Tab.title(tabTitle).should('be.visible'); - e2e.components.Tab.active().should('have.text', tabTitle); - - expectDrawerClose(); -}; diff --git a/e2e/various-suite/keybinds.spec.ts b/e2e/various-suite/keybinds.spec.ts deleted file mode 100644 index c3a8b71359d..00000000000 --- a/e2e/various-suite/keybinds.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { e2e } from '../utils'; -import { fromBaseUrl } from '../utils/support/url'; - -// Skipping due to race conditions with same old arch test e2e/various-suite/keybinds.spec.ts -describe.skip('Keyboard shortcuts', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - - cy.visit(fromBaseUrl('/')); - - // wait for the page to load - e2e.components.Panels.Panel.title('Latest from the blog').should('be.visible'); - }); - - it('sequence shortcuts should work', () => { - cy.get('body').type('ge'); - e2e.pages.Explore.General.container().should('be.visible'); - - cy.get('body').type('gp'); - e2e.components.UserProfile.preferencesSaveButton().should('be.visible'); - - cy.get('body').type('gh'); - e2e.components.Panels.Panel.title('Latest from the blog').should('be.visible'); - }); - - it('ctrl+z should zoom out the time range', () => { - cy.get('body').type('ge'); - e2e.pages.Explore.General.container().should('be.visible'); - - // Time range is 1 minute, so each shortcut press should jump back or forward by 1 minute - e2e.flows.setTimeRange({ - from: '2024-06-05 10:05:00', - to: '2024-06-05 10:06:00', - zone: 'Browser', - }); - e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query'); - - cy.get('body').type('{ctrl}z'); - e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query'); - let expectedRange = `Time range selected: 2024-06-05 10:03:30 to 2024-06-05 10:07:30`; - e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange); - }); - - it('time range shortcuts should work', () => { - cy.get('body').type('ge'); - e2e.pages.Explore.General.container().should('be.visible'); - - // Time range is 1 minute, so each shortcut press should jump back or forward by 1 minute - e2e.flows.setTimeRange({ - from: '2024-06-05 10:05:00', - to: '2024-06-05 10:06:00', - zone: 'Browser', - }); - e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query'); - let expectedRange = `Time range selected: 2024-06-05 10:05:00 to 2024-06-05 10:06:00`; - e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange); - - cy.log('Trying one shift-left'); - cy.get('body').type('t{leftarrow}'); - e2e.components.RefreshPicker.runButtonV2().should('have.text', 'Run query'); - expectedRange = `Time range selected: 2024-06-05 10:04:00 to 2024-06-05 10:05:00`; // 1 min back - e2e.components.TimePicker.openButton().should('have.attr', 'aria-label', expectedRange); - }); -}); diff --git a/e2e/various-suite/loki-query-builder.spec.ts b/e2e/various-suite/loki-query-builder.spec.ts deleted file mode 100644 index 45b527fca5b..00000000000 --- a/e2e/various-suite/loki-query-builder.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { e2e } from '../utils'; - -const MISSING_LABEL_FILTER_ERROR_MESSAGE = 'Select at least 1 label filter (label and value)'; -const dataSourceName = 'LokiBuilder'; -const addDataSource = () => { - e2e.flows.addDataSource({ - type: 'Loki', - expectedAlertMessage: 'Unable to connect with Loki. Please check the server logs for more details.', - name: dataSourceName, - form: () => { - cy.get('#connection-url').type('http://loki-url:3100'); - }, - }); -}; - -const finalQuery = 'rate({instance=~"instance1|instance2"} | logfmt | __error__=`` [$__auto]'; - -describe('Loki query builder', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - - cy.request({ - url: `${Cypress.env('BASE_URL')}/api/datasources/name/${dataSourceName}`, - failOnStatusCode: false, - }).then((response) => { - if (response.isOkStatusCode) { - return; - } - addDataSource(); - }); - }); - - it('should be able to use all modes', () => { - cy.intercept(/labels\?/, (req) => { - req.reply({ status: 'success', data: ['instance', 'job', 'source'] }); - }).as('labelsRequest'); - - cy.intercept(/series?/, (req) => { - req.reply({ status: 'success', data: [{ instance: 'instance1' }] }); - }); - - cy.intercept(/values/, (req) => { - req.reply({ status: 'success', data: ['instance1', 'instance2'] }); - }).as('valuesRequest'); - - cy.intercept(/index\/stats/, (req) => { - req.reply({ streams: 2, chunks: 2660, bytes: 2721792, entries: 14408 }); - }); - - // Go to Explore and choose Loki data source - e2e.pages.Explore.visit(); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(dataSourceName).scrollIntoView().should('be.visible').click(); - - // Start in builder mode, click and choose query pattern - e2e.components.QueryBuilder.queryPatterns().click(); - cy.contains('Log query starters').click(); - cy.contains('Use this query').click(); - cy.contains('No pipeline errors').should('be.visible'); - cy.contains('Logfmt').should('be.visible'); - cy.contains('{} | logfmt | __error__=``').should('be.visible'); - - // Add operation - cy.contains('Operations').should('be.visible').click(); - cy.contains('Range functions').should('be.visible').click(); - cy.contains('Rate').should('be.visible').click(); - cy.contains('rate({} | logfmt | __error__=`` [$__auto]').should('be.visible'); - - // Check for expected error - cy.contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('be.visible'); - - // Add labels to remove error - e2e.components.QueryBuilder.labelSelect().should('be.visible').click(); - // wait until labels are loaded and set on the component before starting to type - e2e.components.QueryBuilder.inputSelect().type('i'); - cy.wait('@labelsRequest'); - e2e.components.QueryBuilder.inputSelect().type('nstance{enter}'); - e2e.components.QueryBuilder.matchOperatorSelect() - .should('be.visible') - .click({ force: true }) - .children('div') - .children('input') - .type('=~{enter}', { force: true }); - e2e.components.QueryBuilder.valueSelect().should('be.visible').click(); - e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance1{enter}'); - cy.wait('@valuesRequest'); - e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance2{enter}'); - - cy.contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('not.exist'); - cy.contains(finalQuery).should('be.visible'); - - // Change to code editor - e2e.components.RadioButton.container().filter(':contains("Code")').click(); - - // We need to test this manually because the final query is split into separate DOM elements using cy.contains(finalQuery).should('be.visible'); does not detect the query. - cy.contains('rate').should('be.visible'); - cy.contains('instance1|instance2').should('be.visible'); - cy.contains('logfmt').should('be.visible'); - cy.contains('__error__').should('be.visible'); - cy.contains('$__auto').should('be.visible'); - - // Checks the explain mode toggle - cy.contains('label', 'Explain').click(); - cy.contains('Fetch all log lines matching label filters.').should('be.visible'); - }); -}); diff --git a/e2e/various-suite/loki-table-explore-to-dash.spec.ts b/e2e/various-suite/loki-table-explore-to-dash.spec.ts deleted file mode 100644 index 6de67e4199e..00000000000 --- a/e2e/various-suite/loki-table-explore-to-dash.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { e2e } from '../utils'; - -const dataSourceName = 'LokiEditor'; -const addDataSource = () => { - e2e.flows.addDataSource({ - type: 'Loki', - expectedAlertMessage: 'Unable to connect with Loki. Please check the server logs for more details.', - name: dataSourceName, - form: () => { - cy.get('#connection-url').type('http://loki-url:3100'); - }, - }); -}; - -const lokiQueryResult = { - status: 'success', - results: { - A: { - status: 200, - frames: [ - { - schema: { - refId: 'A', - meta: { - typeVersion: [0, 0], - custom: { - frameType: 'LabeledTimeValues', - }, - stats: [ - { - displayName: 'Summary: bytes processed per second', - unit: 'Bps', - value: 223921, - }, - { - displayName: 'Summary: total bytes processed', - unit: 'decbytes', - value: 4156, - }, - { - displayName: 'Summary: exec time', - unit: 's', - value: 0.01856, - }, - ], - executedQueryString: 'Expr: {targetLabelName="targetLabelValue"}', - }, - fields: [ - { - name: 'labels', - type: 'other', - typeInfo: { - frame: 'json.RawMessage', - }, - }, - { - name: 'Time', - type: 'time', - typeInfo: { - frame: 'time.Time', - }, - }, - { - name: 'Line', - type: 'string', - typeInfo: { - frame: 'string', - }, - }, - { - name: 'tsNs', - type: 'string', - typeInfo: { - frame: 'string', - }, - }, - { - name: 'id', - type: 'string', - typeInfo: { - frame: 'string', - }, - }, - ], - }, - data: { - values: [ - [ - { - targetLabelName: 'targetLabelValue', - instance: 'server\\1', - job: '"grafana/data"', - nonIndexed: 'value', - place: 'moon', - re: 'one.two$three^four', - source: 'data', - }, - ], - [1700077283237], - [ - '{"_entry":"log text with ANSI \\u001b[31mpart of the text\\u001b[0m [149702545]","counter":"22292","float":"NaN","wave":-0.5877852522916832,"label":"val3","level":"info"}', - ], - ['1700077283237000000'], - ['1700077283237000000_9b025d35'], - ], - }, - }, - ], - }, - }, -}; - -describe.skip('Loki Query Editor', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - afterEach(() => { - e2e.flows.revertAllChanges(); - }); - - beforeEach(() => { - cy.setLocalStorage('grafana.featureToggles', 'logsExploreTableVisualisation=1'); - }); - it('Should be able to add explore table to dashboard', () => { - addDataSource(); - - cy.intercept(/labels?/, (req) => { - req.reply({ status: 'success', data: ['instance', 'job', 'source'] }); - }); - - cy.intercept(/series?/, (req) => { - req.reply({ status: 'success', data: [{ instance: 'instance1' }] }); - }); - - cy.intercept(/\/api\/ds\/query\?ds_type=loki?/, (req) => { - req.reply(lokiQueryResult); - }); - - // Go to Explore and choose Loki data source - e2e.pages.Explore.visit(); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(dataSourceName).scrollIntoView().should('be.visible').click(); - - cy.contains('Code').click({ force: true }); - - // Wait for lazy loading - // const monacoLoadingText = 'Loading...'; - - // e2e.components.QueryField.container().should('be.visible').should('have.text', monacoLoadingText); - e2e.components.QueryField.container() - .find('.view-overlays[role="presentation"]') - .get('.cdr') - .then(($el) => { - const win = $el[0].ownerDocument.defaultView; - const after = win.getComputedStyle($el[0], '::after'); - const content = after.getPropertyValue('content'); - expect(content).to.eq('"Enter a Loki query (run with Shift+Enter)"'); - }); - - // Write a simple query - e2e.components.QueryField.container().type('query').type('{instance="instance1"'); - cy.get('.monaco-editor textarea:first').should(($el) => { - expect($el.val()).to.eq('query{instance="instance1"}'); - }); - - // Submit the query - e2e.components.QueryField.container().type('{shift+enter}'); - // Assert the no-data message is not visible - cy.get('[data-testid="explore-no-data"]').should('not.exist'); - - // Click on the table toggle - cy.contains('Table').click({ force: true }); - - // One row with two cells - cy.get('[role="cell"]').should('have.length', 2); - - cy.contains('label', 'targetLabelName').scrollIntoView(); - cy.contains('label', 'targetLabelName').should('be.visible'); - cy.contains('label', 'targetLabelName').click(); - cy.contains('label', 'targetLabelName').within(() => { - cy.get('input[type="checkbox"]').check({ force: true }); - }); - - cy.contains('label', 'targetLabelName').within(() => { - cy.get('input[type="checkbox"]').should('be.checked'); - }); - - const exploreCells = cy.get('[role="cell"]'); - - // Now we should have a row with 3 columns - exploreCells.should('have.length', 3); - // And a value of "targetLabelValue" - exploreCells.should('contain', 'targetLabelValue'); - - const addToButton = cy.get('[aria-label="Add"]'); - addToButton.should('be.visible'); - addToButton.click(); - - const addToDashboardButton = cy.get('[aria-label="Add to dashboard"]'); - - // Now let's add this to a dashboard - addToDashboardButton.should('be.visible'); - addToDashboardButton.click(); - - const addPanelToDashboardButton = cy.contains('Add panel to dashboard'); - addPanelToDashboardButton.should('be.visible'); - - const openDashboardButton = cy.contains('Open dashboard'); - openDashboardButton.should('be.visible'); - openDashboardButton.click(); - - const panel = cy.get('[data-viz-panel-key="panel-1"]'); - panel.should('be.visible'); - - const cells = panel.find('[role="table"] [role="cell"]'); - // Should have 3 columns - cells.should('have.length', 3); - // Cells contain strings found in log line - cells.contains('"wave":-0.5877852522916832'); - - // column has correct value of "targetLabelValue", need to requery the DOM because of the .contains call above - cy.get('[data-viz-panel-key="panel-1"]').find('[role="table"] [role="cell"]').contains('targetLabelValue'); - }); -}); diff --git a/e2e/various-suite/navigation.spec.ts b/e2e/various-suite/navigation.spec.ts deleted file mode 100644 index b422bfcc74a..00000000000 --- a/e2e/various-suite/navigation.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { e2e } from '../utils'; -import { fromBaseUrl } from '../utils/support/url'; - -describe('Docked Navigation', () => { - beforeEach(() => { - // This is a breakpoint where the mega menu can be docked (and docked is the default state) - cy.viewport(1280, 800); - cy.clearAllLocalStorage(); - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - - cy.visit(fromBaseUrl('/')); - }); - - it('should remain un-docked when reloading the page', () => { - // Undock the menu - cy.get('[aria-label="Undock menu"]').click(); - - e2e.components.NavMenu.Menu().should('not.exist'); - - cy.reload(); - e2e.components.NavMenu.Menu().should('not.exist'); - }); - - it('Can re-dock after undock', () => { - // Undock the menu - cy.get('[aria-label="Undock menu"]').click(); - cy.get('[aria-label="Open menu"]').click(); - cy.get('[aria-label="Dock menu"]').click(); - - e2e.components.NavMenu.Menu().should('be.visible'); - }); - - it('should remain in same state when navigating to another page', () => { - // Undock the menu - cy.get('[aria-label="Undock menu"]').click(); - - // Navigate - cy.get('[aria-label="Open menu"]').click(); - cy.contains('a', 'Administration').click(); - - // Still undocked - e2e.components.NavMenu.Menu().should('not.exist'); - - // dock the menu - cy.get('[aria-label="Open menu"]').click(); - cy.get('[aria-label="Dock menu"]').click(); - - // Navigate - cy.contains('a', 'Users').click(); - // Still docked - e2e.components.NavMenu.Menu().should('be.visible'); - }); - - it('should undock on smaller viewport sizes', () => { - cy.viewport(1120, 1080); - cy.reload(); - - e2e.components.NavMenu.Menu().should('not.exist'); - }); -}); diff --git a/e2e/various-suite/pie-chart.spec.ts b/e2e/various-suite/pie-chart.spec.ts deleted file mode 100644 index c6d537a40a2..00000000000 --- a/e2e/various-suite/pie-chart.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { selectors } from '@grafana/e2e-selectors'; - -import { e2e } from '../utils'; - -describe('Pie Chart Panel', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Pie Chart rendering e2e tests', () => { - // open Panel Tests - Pie Chart - e2e.flows.openDashboard({ uid: 'lVE-2YFMz' }); - - cy.get( - `[data-viz-panel-key="panel-11"] [data-testid^="${selectors.components.Panels.Visualization.PieChart.svgSlice}"]` - ).should('have.length', 5); - }); -}); diff --git a/e2e/various-suite/prometheus-annotations.spec.ts b/e2e/various-suite/prometheus-annotations.spec.ts deleted file mode 100644 index 135d08bcf9d..00000000000 --- a/e2e/various-suite/prometheus-annotations.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { e2e } from '../utils'; -import { addDashboard } from '../utils/flows'; - -import { createPromDS, getResources } from './helpers/prometheus-helpers'; - -const DATASOURCE_ID = 'Prometheus'; - -const DATASOURCE_NAME = 'aprometheusAnnotationDS'; - -/** - * Click dashboard settings and then the variables tab - * - */ -function navigateToAnnotations() { - e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click(); - e2e.components.NavToolbar.editDashboard.settingsButton().should('be.visible').click(); - e2e.components.Tab.title('Annotations').click(); -} - -function addPrometheusAnnotation(annotationName: string) { - e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2().click(); - getResources(); - e2e.pages.Dashboard.Settings.Annotations.Settings.name().clear().type(annotationName); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - cy.contains(DATASOURCE_NAME).scrollIntoView().should('be.visible').click(); -} - -describe('Prometheus annotations', () => { - beforeEach(() => { - createPromDS(DATASOURCE_ID, DATASOURCE_NAME); - }); - - it('should navigate to variable query editor', () => { - const annotationName = 'promAnnotation'; - addDashboard(); - navigateToAnnotations(); - addPrometheusAnnotation(annotationName); - - e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser - .openButton() - .contains('Metrics browser') - .click(); - - e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric().should('exist').type('met'); - - e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser - .metricList() - .should('exist') - .contains('metric1') - .click(); - - e2e.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery().should('exist').click(); - - e2e.components.DataSource.Prometheus.queryEditor.code.queryField().should('exist').contains('metric1'); - - // check for other parts of the annotations - // min step - e2e.components.DataSource.Prometheus.annotations.minStep().should('exist'); - - // title - e2e.components.DataSource.Prometheus.annotations.title().scrollIntoView().should('exist'); - // tags - e2e.components.DataSource.Prometheus.annotations.tags().scrollIntoView().should('exist'); - // text - e2e.components.DataSource.Prometheus.annotations.text().scrollIntoView().should('exist'); - // series value as timestamp - e2e.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp().scrollIntoView().should('exist'); - - e2e.components.NavToolbar.editDashboard.backToDashboardButton().should('be.visible').click(); - - // check that annotation exists - cy.get('body').contains(annotationName); - }); -}); diff --git a/e2e/various-suite/query-editor.spec.ts b/e2e/various-suite/query-editor.spec.ts deleted file mode 100644 index f9149f7b280..00000000000 --- a/e2e/various-suite/query-editor.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { e2e } from '../utils'; -import { waitForMonacoToLoad } from '../utils/support/monaco'; - -describe('Query editor', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - // x-ing to bypass this flaky test. - // Will rewrite in plugin-e2e with this issue - xit('Undo should work in query editor for prometheus -- test CI.', () => { - e2e.pages.Explore.visit(); - e2e.components.DataSourcePicker.container().should('be.visible').click(); - - cy.contains('gdev-prometheus').scrollIntoView().should('be.visible').click(); - const queryText = `rate(http_requests_total{job="grafana"}[5m])`; - - e2e.components.RadioButton.container().filter(':contains("Code")').should('be.visible').click(); - - waitForMonacoToLoad(); - - e2e.components.QueryField.container().type(queryText, { parseSpecialCharSequences: false }).type('{backspace}'); - - cy.contains(queryText.slice(0, -1)).should('be.visible'); - - e2e.components.QueryField.container().type(e2e.typings.undo()); - - cy.contains(queryText).should('be.visible'); - - e2e.components.Alert.alertV2('error').should('not.be.visible'); - }); -}); diff --git a/e2e/various-suite/return-to-previous.spec.ts b/e2e/various-suite/return-to-previous.spec.ts deleted file mode 100644 index 395c233d76a..00000000000 --- a/e2e/various-suite/return-to-previous.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { e2e } from '../utils'; - -describe('ReturnToPrevious button', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - - cy.visit('/alerting/list'); - e2e.components.AlertRules.groupToggle().first().click(); - e2e.components.AlertRules.toggle().click(); - cy.get('a[title="View"]').click(); - cy.url().as('alertRuleUrl'); - cy.get('a').contains('View panel').click(); - }); - - it('should appear when changing context and go back to alert rule when clicking "Back"', () => { - // check whether all elements of RTP are available - e2e.components.ReturnToPrevious.buttonGroup().should('be.visible'); - e2e.components.ReturnToPrevious.dismissButton().should('be.visible'); - e2e.components.ReturnToPrevious.backButton() - .find('span') - .contains('Back to e2e-ReturnToPrevious-test') - .should('be.visible') - .click(); - - // check whether the RTP button leads back to alert rule - cy.get('@alertRuleUrl').then((alertRuleUrl) => { - cy.url().should('eq', alertRuleUrl); - }); - }); - - it('should disappear when clicking "Dismiss"', () => { - e2e.components.ReturnToPrevious.dismissButton().should('be.visible').click(); - e2e.components.ReturnToPrevious.buttonGroup().should('not.exist'); - }); - - it('should not persist when going back to the alert rule details view', () => { - e2e.components.ReturnToPrevious.buttonGroup().should('be.visible'); - - cy.visit('/alerting/list'); - e2e.components.AlertRules.groupToggle().first().click(); - cy.get('a[title="View"]').click(); - e2e.components.ReturnToPrevious.buttonGroup().should('not.exist'); - }); - - it('should override the button label and change the href when user changes alert rules', () => { - e2e.components.ReturnToPrevious.backButton() - .find('span') - .contains('Back to e2e-ReturnToPrevious-test') - .should('be.visible'); - - cy.visit('/alerting/list'); - - e2e.components.AlertRules.groupToggle().last().click(); - cy.get('a[title="View"]').click(); - cy.url().as('alertRule2Url'); - cy.get('a').contains('View panel').click(); - - e2e.components.ReturnToPrevious.backButton() - .find('span') - .contains('Back to e2e-ReturnToPrevious-test-2') - .should('be.visible') - .click(); - - e2e.components.ReturnToPrevious.buttonGroup().should('not.exist'); - - // check whether the RTP button leads back to alert rule - cy.get('@alertRule2Url').then((alertRule2Url) => { - cy.url().should('eq', alertRule2Url); - }); - }); -}); diff --git a/e2e/various-suite/select-focus.spec.ts b/e2e/various-suite/select-focus.spec.ts deleted file mode 100644 index 5ff66306b35..00000000000 --- a/e2e/various-suite/select-focus.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { e2e } from '../utils'; - -describe('Select focus/unfocus tests', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it.skip('Tests select focus/unfocus scenarios', () => { - e2e.flows.openDashboard({ uid: '5SdHCadmz' }); - e2e.components.PageToolbar.item('Dashboard settings').click(); - - e2e.components.FolderPicker.containerV2() - .should('be.visible') - .within(() => { - cy.get('#dashboard-folder-input').should('be.visible').click(); - }); - - e2e.components.Select.option().should('be.visible').first().click(); - - e2e.components.FolderPicker.containerV2() - .should('be.visible') - .within(() => { - cy.get('#dashboard-folder-input').should('exist').should('have.focus'); - }); - - e2e.pages.Dashboard.Settings.General.title().click(); - - e2e.components.FolderPicker.containerV2() - .should('be.visible') - .within(() => { - cy.get('#dashboard-folder-input').should('exist').should('not.have.focus'); - }); - }); -}); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts deleted file mode 100644 index df026f66033..00000000000 --- a/e2e/various-suite/solo-route.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { e2e } from '../utils'; - -describe('Solo Route', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Can view panels with shared queries in fullscreen', () => { - // open Panel Tests - Bar Gauge - e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); - - cy.get('canvas').should('have.length', 6); - }); - - it('Can view solo panel in scenes', () => { - // open Panel Tests - Graph NG - e2e.pages.SoloPanel.visit( - 'TkZXxlNG3/panel-tests-graph-ng?orgId=1&from=1699954597665&to=1699956397665&panelId=54&__feature.dashboardSceneSolo=true' - ); - - e2e.components.Panels.Panel.title('Interpolation: Step before').should('exist'); - cy.contains('uplot-main-div').should('not.exist'); - }); - - it('Can view solo repeated panel in scenes', () => { - // open Panel Tests - Graph NG - e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=A$panel-2&__feature.dashboardSceneSolo=true' - ); - - e2e.components.Panels.Panel.title('server=A').should('exist'); - cy.contains('uplot-main-div').should('not.exist'); - }); - - it('Can view solo in repeated row and panel in scenes', () => { - // open Panel Tests - Graph NG - e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=B$2$panel-2&__feature.dashboardSceneSolo=true' - ); - - e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist'); - cy.contains('uplot-main-div').should('not.exist'); - }); -}); diff --git a/e2e/various-suite/verify-i18n.spec.ts b/e2e/various-suite/verify-i18n.spec.ts deleted file mode 100644 index a664e714e66..00000000000 --- a/e2e/various-suite/verify-i18n.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { e2e } from '../utils'; -import { fromBaseUrl } from '../utils/support/url'; - -describe('Verify i18n', () => { - const I18N_USER = 'i18n-test'; - const I18N_PASSWORD = 'i18n-test'; - - // create a new user to isolate the language changes from other tests - before(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - cy.request({ - method: 'POST', - url: fromBaseUrl('/api/admin/users'), - body: { - email: I18N_USER, - login: I18N_USER, - name: I18N_USER, - password: I18N_PASSWORD, - }, - }).then((response) => { - cy.wrap(response.body.uid).as('uid'); - }); - }); - - // remove the user created in the before hook - after(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - cy.get('@uid').then((uid) => { - cy.request({ - method: 'DELETE', - url: fromBaseUrl(`/api/admin/users/${uid}`), - }); - }); - }); - - beforeEach(() => { - e2e.flows.login(I18N_USER, I18N_PASSWORD); - }); - - // map between languages in the language picker and the corresponding translation of the 'Language' label - const languageMap: Record = { - Deutsch: 'Sprache', - English: 'Language', - Español: 'Idioma', - Français: 'Langue', - 'Português Brasileiro': 'Idioma', - '中文(简体)': '语言', - }; - - // basic test which loops through the defined languages in the picker - // and verifies that the corresponding label is translated correctly - it('loads all the languages correctly', () => { - cy.visit('/profile'); - const LANGUAGE_SELECTOR = '[id="language-preference-select"]'; - cy.wrap(Object.entries(languageMap)).each(([language, label]: [string, string]) => { - cy.get(LANGUAGE_SELECTOR).should('not.be.disabled'); - cy.get(LANGUAGE_SELECTOR).click(); - cy.get(LANGUAGE_SELECTOR).clear().type(language).type('{downArrow}{enter}'); - e2e.components.UserProfile.preferencesSaveButton().click(); - cy.contains('label', label).should('be.visible'); - cy.get(LANGUAGE_SELECTOR).should('have.value', language); - }); - }); -}); diff --git a/e2e/various-suite/visualization-suggestions.spec.ts b/e2e/various-suite/visualization-suggestions.spec.ts deleted file mode 100644 index c5dfb03ef93..00000000000 --- a/e2e/various-suite/visualization-suggestions.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { e2e } from '../utils'; - -describe('Visualization suggestions', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('Should be shown and clickable', () => { - e2e.flows.openDashboard({ uid: 'aBXrJ0R7z', queryParams: { '__feature.tableNextGen': false, editPanel: 9 } }); - - // Try visualization suggestions - e2e.components.PanelEditor.toggleVizPicker().click(); - e2e.components.RadioButton.container().filter(':contains("Suggestions")').click(); - - // Verify we see suggestions - e2e.components.VisualizationPreview.card('Line chart').should('be.visible'); - - // Verify search works - cy.get('[placeholder="Search for..."]').type('Table'); - // Should no longer see line chart - e2e.components.VisualizationPreview.card('Line chart').should('not.exist'); - - // Select a visualisation - e2e.components.VisualizationPreview.card('Table').click(); - e2e.components.Panels.Visualization.Table.header().should('be.visible'); - }); -}); From 03bcd604fc4b811b0a027821f573a929a2ac5d63 Mon Sep 17 00:00:00 2001 From: Jacob Valdez Date: Wed, 20 Aug 2025 10:49:28 -0500 Subject: [PATCH 18/53] docs: fixing typo in config docs (#109882) --- docs/sources/setup-grafana/configure-grafana/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 67cba858ef2..cedf7b619e6 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -796,7 +796,7 @@ The following example limits access to the backend of a single plugin: #### `angular_support_enabled` -This is set to false by default, meaning that the angular framework and support components aren't be loaded. +This is set to false by default, meaning that the angular framework and support components aren't loaded. This means that all [plugins](../../developers/angular_deprecation/angular-plugins/) and core features that depend on angular support won't work. The core features that depend on angular are: From 170c84c3f834f5b48d2b7ef8f153906ccb2a9286 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Wed, 20 Aug 2025 11:50:39 -0400 Subject: [PATCH 19/53] MoveProvisionedDashboard: bug fix when selecting root folder, error is showing (#109638) * MoveProvisionedDashboard: root folder error fix * Remove provisioned badge when whole instance is provisioned, root folder checkbox fix * If root level only have one provisioned folder, allow select all on subitems * remove button tooltip and remove comment * regenerate API clients after schema updates --------- Co-authored-by: Alex Khomenko --- .../NestedFolderPicker/FolderRepo.tsx | 6 +- .../useSelectionRepoValidation.ts | 10 ++- .../components/BrowseView.tsx | 32 +++++++++- .../BulkMoveProvisionedResource.tsx | 53 +++++++++++----- .../components/BulkActions/utils.test.ts | 59 ++++++++++++++++++ .../components/BulkActions/utils.ts | 62 ++++++++++++++++++- .../components/CheckboxCell.tsx | 6 +- .../browse-dashboards/state/reducers.ts | 14 ++--- .../settings/MoveProvisionedDashboardForm.tsx | 18 +++--- public/locales/en-US/grafana.json | 3 +- 10 files changed, 217 insertions(+), 46 deletions(-) create mode 100644 public/app/features/browse-dashboards/components/BulkActions/utils.test.ts diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index be50e30b5be..b0f750d991d 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -1,6 +1,7 @@ import { t } from '@grafana/i18n'; import { Badge, Stack } from '@grafana/ui'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { NestedFolderDTO } from 'app/features/search/service/types'; import { FolderDTO, FolderListItemDTO } from 'app/types/folders'; @@ -14,7 +15,10 @@ export function FolderRepo({ folder }: Props) { // folder is not present // folder have parentUID // folder is not managed - const skipRender = !folder || ('parentUID' in folder && folder.parentUID) || !folder.managedBy; + // if whole instance is provisioned + const isProvisionedInstance = useIsProvisionedInstance(); + const skipRender = + !folder || ('parentUID' in folder && folder.parentUID) || !folder.managedBy || isProvisionedInstance; const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: skipRender ? undefined : folder?.uid, diff --git a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts index cde7e9d56e5..b91cf70d01c 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts +++ b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts @@ -2,6 +2,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { config } from '@grafana/runtime'; import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; import { getIsReadOnlyRepo } from 'app/features/provisioning/utils/repository'; import { useSelector } from 'app/types/store'; @@ -15,6 +16,7 @@ export function useSelectionRepoValidation(selectedItems: Omit 0 ? repoUIDs[0] : undefined; const isCrossRepo = new Set(repoUIDs).size > 1; - const isInLockedRepo = (uid: string) => !selectedItemsRepoUID || getRepoUid(uid) === selectedItemsRepoUID; + const isInLockedRepo = (uid: string) => { + // if whole instance is provisioned, all items are considered in the locked (same) repo + if (isProvisionedInstance) { + return true; + } + return !selectedItemsRepoUID || getRepoUid(uid) === selectedItemsRepoUID; + }; const isUidInReadOnlyRepo = (uid: string) => { const repo = getRepositoryByUid(getRepoUid(uid)); return repo ? getIsReadOnlyRepo(repo) : false; diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index 5cd79f3341f..b5895c5e376 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -1,9 +1,13 @@ -import { useCallback } from 'react'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useCallback, useMemo } from 'react'; import { Trans, t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { CallToActionCard, EmptyState, LinkButton, TextLink } from '@grafana/ui'; +import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; import { DashboardViewItem } from 'app/features/search/types'; -import { useDispatch } from 'app/types/store'; +import { useDispatch, useSelector } from 'app/types/store'; import { PAGE_SIZE } from '../api/services'; import { fetchNextChildrenPage } from '../state/actions'; @@ -13,6 +17,7 @@ import { useChildrenByParentUIDState, useBrowseLoadingStatus, useLoadNextChildrenPage, + rootItemsSelector, } from '../state/hooks'; import { setFolderOpenState, setItemSelectionState, setAllSelection } from '../state/slice'; import { BrowseDashboardsState, DashboardTreeSelection, SelectionState, BrowseDashboardsPermissions } from '../types'; @@ -34,6 +39,27 @@ export function BrowseView({ folderUID, width, height, permissions }: BrowseView const selectedItems = useCheckboxSelectionState(); const childrenByParentUID = useChildrenByParentUIDState(); const canSelect = canSelectItems(permissions); + const isProvisionedInstance = useIsProvisionedInstance(); + const provisioningEnabled = config.featureToggles.provisioning; + const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled ? skipToken : undefined); + const rootItems = useSelector(rootItemsSelector); + + const excludeUIDs = useMemo(() => { + if (isProvisionedInstance || !provisioningEnabled) { + return []; + } + if (provisioningEnabled) { + // if only one repo folder and no local folders, then don't exclude it from selection + if (rootItems?.items.length === 1 && settingsData?.items.length === 1) { + return []; + } + // loop through settingsData to find all available repo name, and exclude them from select all action + // repo root folder is not actionable on browse dashboards page + return settingsData?.items.map((repo) => repo.name); + } + + return []; + }, [isProvisionedInstance, settingsData, provisioningEnabled, rootItems]); const handleFolderClick = useCallback( (clickedFolderUID: string, isOpen: boolean) => { @@ -164,7 +190,7 @@ export function BrowseView({ folderUID, width, height, permissions }: BrowseView height={height} isSelected={isSelected} onFolderClick={handleFolderClick} - onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState, folderUID }))} + onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState, folderUID, excludeUIDs }))} onItemSelectionChange={handleItemSelectionChange} isItemLoaded={isItemLoaded} requestLoadMore={handleLoadMore} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx index f20d41d650d..9a03abcc9e2 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx @@ -40,14 +40,24 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions // Hooks const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob(); const methods = useForm({ defaultValues: initialValues }); - const { handleSubmit, watch } = methods; + const { + handleSubmit, + watch, + setError, + clearErrors, + formState: { errors }, + } = methods; const workflow = watch('workflow'); // Get target folder data const { data: targetFolder } = useGetFolderQuery(targetFolderUID ? { name: targetFolderUID } : skipToken); const setupMoveOperation = () => { - const targetFolderPathInRepo = getTargetFolderPathInRepo({ targetFolder }); + const targetFolderPathInRepo = getTargetFolderPathInRepo({ + targetFolderUID, + targetFolder, + repoName: repository.name, + }); const resources = collectSelectedItems(selectedItems); return { targetFolderPathInRepo, resources }; @@ -60,12 +70,15 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions const { targetFolderPathInRepo, resources } = setupMoveOperation(); if (!targetFolderPathInRepo) { - throw new Error( - t( + setError('targetFolderUID', { + type: 'manual', + message: t( 'browse-dashboards.bulk-move-resources-form.error-no-target-folder-path', - 'Target folder path in repository is invalid, please select another folder.' - ) - ); + 'Target folder path is invalid or empty, please select again.' + ), + }); + setHasSubmitted(false); + return; } // Create the move job spec @@ -73,7 +86,7 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions action: 'move', move: { ref: data.workflow === 'write' ? undefined : data.ref, - targetPath: `${targetFolderPathInRepo}/`, + targetPath: targetFolderPathInRepo, resources, }, }; @@ -90,7 +103,7 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions result.error, ], }); - setHasSubmitted(false); // Reset submit state so user can try again + setHasSubmitted(false); } }; @@ -110,8 +123,19 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions ) : ( <> {/* Target folder selection */} - - + + { + setTargetFolderUID(uid || ''); + clearErrors('targetFolderUID'); + }} + />