From 47b94cf17bd1480b7d98d328dacb190065b19087 Mon Sep 17 00:00:00 2001 From: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Thu, 20 Mar 2025 07:43:31 -0600 Subject: [PATCH 01/79] Dashboard: Fix Core Panel Migrations - table panel (#102146) *add the automigrate logic to the dashboard migrator * Add unit tests to the dashboard migrator version 24 --- .../dashboard/state/DashboardMigrator.test.ts | 115 ++++++++++++++++++ .../dashboard/state/DashboardMigrator.ts | 9 ++ 2 files changed, 124 insertions(+) diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index 70a80fb2da1..d2b6715e4a6 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -2450,6 +2450,121 @@ describe('when migrating time_options in timepicker', () => { }); }); +describe('when migrating table panels at schema version 24', () => { + let model: DashboardModel; + + beforeEach(() => { + config.featureToggles.autoMigrateOldPanels = true; + }); + + afterEach(() => { + config.featureToggles.autoMigrateOldPanels = false; + model = new DashboardModel({ + panels: [], + schemaVersion: 23, + }); + }); + + it('should migrate Angular table to table and set autoMigrateFrom', () => { + model = new DashboardModel({ + panels: [ + { + id: 1, + type: 'table', + // @ts-expect-error + legend: true, + styles: [{ thresholds: ['10', '20', '30'] }, { thresholds: ['100', '200', '300'] }], + targets: [{ refId: 'A' }, {}], + }, + ], + schemaVersion: 23, + }); + + // Verify the panel was migrated to table, yes this is intentional + // when autoMigrateOldPanels is enabled, we should migrate to table + // and add the autoMigrateFrom property + expect(model.panels[0].type).toBe('table'); + // Verify autoMigrateFrom was set + expect(model.panels[0].autoMigrateFrom).toBe('table-old'); + }); + + it('should migrate Angular table to table-old when autoMigrateOldPanels is disabled ', () => { + config.featureToggles.autoMigrateOldPanels = false; + model = new DashboardModel({ + panels: [ + { + id: 1, + type: 'table', + // @ts-expect-error + legend: true, + styles: [{ thresholds: ['10', '20', '30'] }, { thresholds: ['100', '200', '300'] }], + targets: [{ refId: 'A' }, {}], + }, + ], + schemaVersion: 23, + }); + + // Verify the panel was migrated to table, yes this is intentional + // when autoMigrateOldPanels is enabled, we should migrate to table + // and add the autoMigrateFrom property + expect(model.panels[0].type).toBe('table-old'); + // Verify autoMigrateFrom was set + expect(model.panels[0].autoMigrateFrom).toBe(undefined); + }); + + it('should not migrate Angular table without styles', () => { + model = new DashboardModel({ + panels: [ + { + id: 1, + type: 'table', + // No styles property + }, + ], + schemaVersion: 23, + }); + + // Verify the panel was not migrated + expect(model.panels[0].type).toBe('table'); + expect(model.panels[0].autoMigrateFrom).toBeUndefined(); + }); + + it('should not migrate React table (table2)', () => { + model = new DashboardModel({ + panels: [ + { + id: 1, + type: 'table2', + }, + ], + schemaVersion: 23, + }); + + // Verify the panel was not migrated + expect(model.panels[0].type).toBe('table2'); + expect(model.panels[0].autoMigrateFrom).toBeUndefined(); + }); + + it('should not migrate non-table panels when autoMigrateOldPanels is disabled', () => { + // disable autoMigrateOldPanels + config.featureToggles.autoMigrateOldPanels = false; + model = new DashboardModel({ + panels: [ + { + id: 1, + type: 'grafana-worldmap-panel', + title: 'world map panel', + }, + ], + schemaVersion: 23, + }); + + // Verify the panel was not migrated + expect(model.panels[0].type).toBe('grafana-worldmap-panel'); + expect(model.panels[0].autoMigrateFrom).toBeUndefined(); + }); +}); + function createRow(options: any, panelDescriptions: any[]) { const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN; const { collapse, showTitle, title, repeat, repeatIteration } = options; diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index bab206f9792..e1f6b0a85fa 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -65,6 +65,7 @@ import { import { DashboardModel } from './DashboardModel'; import { PanelModel } from './PanelModel'; +import { getPanelPluginToMigrateTo } from './getPanelPluginToMigrateTo'; standardEditorsRegistry.setInit(getAllOptionEditors); standardFieldConfigEditorRegistry.setInit(getAllStandardFieldConfigs); @@ -626,6 +627,14 @@ export class DashboardMigrator { return panel; } panel.type = wasAngularTable ? 'table-old' : 'table'; + // Hacky way to call the automigrate feature + if (panel.type === 'table-old') { + const newType = getPanelPluginToMigrateTo(panel); + if (newType) { + panel.autoMigrateFrom = panel.type; + panel.type = newType; + } + } return panel; }); } From d3832c7f8bc0c1542b1841aa360aa359251daeee Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Thu, 20 Mar 2025 15:45:20 +0200 Subject: [PATCH 02/79] Dynamic Dashboards: Implement new toolbar (#102195) --- .../scene/NavToolbarActions.tsx | 233 ++++++++---------- .../ResponsiveGridLayoutManager.tsx | 20 +- .../scene/new-toolbar/LeftActions.tsx | 79 ++++++ .../scene/new-toolbar/RightActions.tsx | 154 ++++++++++++ .../scene/new-toolbar/ToolbarActionsNew.tsx | 19 ++ .../actions/BackToDashboardButton.tsx | 22 ++ .../actions/DashboardSettingsButton.tsx | 14 ++ .../actions/DiscardLibraryPanelButton.tsx | 18 ++ .../actions/DiscardPanelButton.tsx | 54 ++++ .../actions/EditDashboardSwitch.tsx | 28 +++ .../actions/EditSchemaV2Button.tsx | 12 + .../actions/ExportDashboardButton.tsx | 33 +++ .../actions/MakeDashboardEditableButton.tsx | 22 ++ .../actions/ManagedDashboardBadge.tsx | 31 +++ .../actions/OpenSnapshotOriginButton.tsx | 6 + .../actions/PlayListNextButton.tsx | 16 ++ .../actions/PlayListPreviousButton.tsx | 15 ++ .../actions/PlayListStopButton.tsx | 15 ++ .../actions/PublicDashboardBadge.tsx | 28 +++ .../new-toolbar/actions/SaveDashboard.tsx | 80 ++++++ .../actions/SaveLibraryPanelButton.tsx | 18 ++ .../actions/ShareDashboardButton.tsx | 40 +++ .../actions/ShareExportDashboardButton.tsx | 100 ++++++++ .../scene/new-toolbar/actions/StarButton.tsx | 28 +++ .../new-toolbar/actions/ToolbarSwitch.tsx | 119 +++++++++ .../actions/UnlinkLibraryPanelButton.tsx | 18 ++ .../scene/new-toolbar/types.ts | 14 ++ .../scene/new-toolbar/utils.tsx | 62 +++++ .../sharing/ExportButton/ExportButton.tsx | 6 +- public/locales/en-US/grafana.json | 62 ++++- 30 files changed, 1214 insertions(+), 152 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/ToolbarActionsNew.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/BackToDashboardButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/DashboardSettingsButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardLibraryPanelButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardPanelButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/EditSchemaV2Button.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/ExportDashboardButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/ManagedDashboardBadge.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListNextButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListPreviousButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListStopButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveLibraryPanelButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareDashboardButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareExportDashboardButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/StarButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/ToolbarSwitch.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/actions/UnlinkLibraryPanelButton.tsx create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/types.ts create mode 100644 public/app/features/dashboard-scene/scene/new-toolbar/utils.tsx diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index d8671716331..6b6c0b54f50 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo, ReactNode, useEffect, useId, useState } from 'react'; +import { memo, ReactNode, useEffect, useState } from 'react'; import { GrafanaTheme2, store } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -10,10 +10,8 @@ import { ButtonGroup, Dropdown, Icon, - InlineLabel, Menu, Stack, - Switch, ToolbarButton, ToolbarButtonRow, useStyles2, @@ -39,15 +37,20 @@ import { isLibraryPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton'; import ManagedDashboardNavBarBadge from './ManagedDashboardNavBarBadge'; +import { ToolbarActionsNew } from './new-toolbar/ToolbarActionsNew'; interface Props { dashboard: DashboardScene; } export const NavToolbarActions = memo(({ dashboard }) => { - const id = useId(); + const hasNewToolbar = config.featureToggles.dashboardNewLayouts && config.featureToggles.newDashboardSharingComponent; - const actions = ; + const actions = hasNewToolbar ? ( + + ) : ( + + ); return ; }); @@ -57,8 +60,7 @@ NavToolbarActions.displayName = 'NavToolbarActions'; * This part is split into a separate component to help test this */ export function ToolbarActions({ dashboard }: Props) { - const { isEditing, showHiddenElements, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } = - dashboard.useState(); + const { isEditing, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState(); const { isPlaying } = playlistSrv.useState(); const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false); @@ -77,7 +79,6 @@ export function ToolbarActions({ dashboard }: Props) { // Means we are not in settings view, fullscreen panel or edit panel const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; const isEditingAndShowingDashboard = isEditing && isShowingDashboard; - const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts; const folderRepo = useSelector((state) => selectFolderRepository(state, meta.folderUid)); const isManaged = Boolean(dashboard.isManagedRepository() || folderRepo); @@ -168,97 +169,75 @@ export function ToolbarActions({ dashboard }: Props) { addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions'); } - if (dashboardNewLayouts) { - leftActions.push({ - group: 'hidden-elements', - condition: isEditingAndShowingDashboard, - render: () => ( - - { - evt.stopPropagation(); - dashboard.onToggleHiddenElements(); - }} - data-testid={selectors.components.PageToolbar.itemButton('toggle_hidden_elements')} - /> - - Show hidden - - - ), - }); - } else { - toolbarActions.push({ - group: 'add-panel', - condition: isEditingAndShowingDashboard, - render: () => ( - { - setIsAddPanelMenuOpen(isOpen); - DashboardInteractions.toolbarAddClick(); - }} - overlay={() => ( - - { - const vizPanel = dashboard.onCreateNewPanel(); - DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' }); - dashboard.setState({ editPanel: buildPanelEditScene(vizPanel, true) }); - }} - /> - { - dashboard.onShowAddLibraryPanelDrawer(); - DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' }); - }} - disabled={dashboard.isManagedRepository()} - /> - { - dashboard.onCreateNewRow(); - DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' }); - }} - /> - { - dashboard.pastePanel(); - DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' }); - }} - /> - - )} - placement="bottom" - offset={[0, 6]} + toolbarActions.push({ + group: 'add-panel', + condition: isEditingAndShowingDashboard, + render: () => ( + { + setIsAddPanelMenuOpen(isOpen); + DashboardInteractions.toolbarAddClick(); + }} + overlay={() => ( + + { + const vizPanel = dashboard.onCreateNewPanel(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' }); + dashboard.setState({ editPanel: buildPanelEditScene(vizPanel, true) }); + }} + /> + { + dashboard.onShowAddLibraryPanelDrawer(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' }); + }} + disabled={dashboard.isManagedRepository()} + /> + { + dashboard.onCreateNewRow(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' }); + }} + /> + { + dashboard.pastePanel(); + DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' }); + }} + /> + + )} + placement="bottom" + offset={[0, 6]} + > + - - ), - }); - } + Add + + + + ), + }); toolbarActions.push({ group: 'playlist-actions', @@ -419,27 +398,25 @@ export function ToolbarActions({ dashboard }: Props) { render: () => , }); - if (!dashboardNewLayouts) { - toolbarActions.push({ - group: 'settings', - condition: isEditing && dashboard.canEditDashboard() && isShowingDashboard, - render: () => ( - - ), - }); - } + toolbarActions.push({ + group: 'settings', + condition: isEditing && dashboard.canEditDashboard() && isShowingDashboard, + render: () => ( + + ), + }); toolbarActions.push({ group: 'main-buttons', @@ -627,24 +604,6 @@ export function ToolbarActions({ dashboard }: Props) { }, }); - // Will open a schema v2 editor drawer. Only available with new dashboard layouts. - toolbarActions.push({ - group: 'main-buttons', - condition: uid && dashboardNewLayouts, - render: () => { - return ( - } - key="schema-v2-button" - onClick={() => { - dashboard.openV2SchemaEditor(); - }} - /> - ); - }, - }); - const rightActionsElements: ReactNode[] = renderActionElements(toolbarActions); const leftActionsElements: ReactNode[] = renderActionElements(leftActions); const hasActionsToLeftAndRight = leftActionsElements.length > 0; diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index fe17f5ca802..3cb79c576d4 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -77,18 +77,14 @@ export class ResponsiveGridLayoutManager key: undefined, layout: this.state.layout.clone({ key: undefined, - children: this.state.layout.state.children.map((child) => { - if (child instanceof ResponsiveGridItem) { - return child.clone({ - key: undefined, - body: child.state.body.clone({ - key: getVizPanelKeyForPanelId(dashboardSceneGraph.getNextPanelId(child.state.body)), - }), - }); - } - - return child.clone({ key: undefined }); - }), + children: this.state.layout.state.children.map((child) => + child.clone({ + key: undefined, + body: child.state.body.clone({ + key: getVizPanelKeyForPanelId(dashboardSceneGraph.getNextPanelId(child.state.body)), + }), + }) + ), }), }); } diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx new file mode 100644 index 00000000000..2318694ecd9 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx @@ -0,0 +1,79 @@ +import { css } from '@emotion/css'; + +import { ToolbarButtonRow, useStyles2 } from '@grafana/ui'; + +import { dynamicDashNavActions } from '../../utils/registerDynamicDashNavAction'; +import { DashboardScene } from '../DashboardScene'; + +import { ManagedDashboardBadge } from './actions/ManagedDashboardBadge'; +import { OpenSnapshotOriginButton } from './actions/OpenSnapshotOriginButton'; +import { PublicDashboardBadge } from './actions/PublicDashboardBadge'; +import { StarButton } from './actions/StarButton'; +import { getDynamicActions, renderActionElements, useIsManagedRepository } from './utils'; + +export const LeftActions = ({ dashboard }: { dashboard: DashboardScene }) => { + const styles = useStyles2(getStyles); + const { editview, editPanel, isEditing, uid, meta, viewPanelScene } = dashboard.useState(); + + const hasEditView = Boolean(editview); + const isViewingPanel = Boolean(viewPanelScene); + const isEditingDashboard = Boolean(isEditing); + const isEditingPanel = Boolean(editPanel); + const isPublicDashboard = Boolean(meta.publicDashboardEnabled); + const hasUid = Boolean(uid); + const canEdit = Boolean(meta.canEdit); + const canStar = Boolean(meta.canStar); + const isSnapshot = Boolean(meta.isSnapshot); + const isShowingDashboard = !hasEditView && !isViewingPanel && !isEditingPanel; + const isManagedRepository = useIsManagedRepository(dashboard); + + const elements = renderActionElements( + [ + // This adds the presence indicators in enterprise + ...getDynamicActions(dynamicDashNavActions.left, 'left-dynamic', !isEditingPanel), + { + key: 'star-button', + component: StarButton, + group: 'actions', + condition: hasUid && canStar && isShowingDashboard && !isEditingDashboard, + }, + { + key: 'public-dashboard-badge', + component: PublicDashboardBadge, + group: 'actions', + condition: isPublicDashboard && hasUid && canStar && isShowingDashboard && !isEditingDashboard, + }, + { + key: 'managed-dashboard-badge', + component: ManagedDashboardBadge, + group: 'actions', + condition: isManagedRepository && canEdit, + }, + { + key: 'open-snapshot-origin-button', + component: OpenSnapshotOriginButton, + group: 'actions', + condition: isSnapshot && !isEditingDashboard, + }, + // This adds the presence indicators in enterprise + ...getDynamicActions(dynamicDashNavActions.right, 'right-dynamic', !isEditingPanel && !isEditingDashboard), + ], + dashboard + ); + + if (elements.length === 0) { + return null; + } + + return ( + + {elements} + + ); +}; + +const getStyles = () => ({ + container: css({ + flex: 1, + }), +}); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx new file mode 100644 index 00000000000..b809a9fb90a --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx @@ -0,0 +1,154 @@ +import { css } from '@emotion/css'; + +import { ToolbarButtonRow, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; + +import { isLibraryPanel } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; + +import { BackToDashboardButton } from './actions/BackToDashboardButton'; +import { DashboardSettingsButton } from './actions/DashboardSettingsButton'; +import { DiscardLibraryPanelButton } from './actions/DiscardLibraryPanelButton'; +import { DiscardPanelButton } from './actions/DiscardPanelButton'; +import { EditDashboardSwitch } from './actions/EditDashboardSwitch'; +import { EditSchemaV2Button } from './actions/EditSchemaV2Button'; +import { ExportDashboardButton } from './actions/ExportDashboardButton'; +import { MakeDashboardEditableButton } from './actions/MakeDashboardEditableButton'; +import { PlayListNextButton } from './actions/PlayListNextButton'; +import { PlayListPreviousButton } from './actions/PlayListPreviousButton'; +import { PlayListStopButton } from './actions/PlayListStopButton'; +import { SaveDashboard } from './actions/SaveDashboard'; +import { SaveLibraryPanelButton } from './actions/SaveLibraryPanelButton'; +import { ShareDashboardButton } from './actions/ShareDashboardButton'; +import { UnlinkLibraryPanelButton } from './actions/UnlinkLibraryPanelButton'; +import { renderActionElements } from './utils'; + +export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => { + const styles = useStyles2(getStyles); + const { editPanel, editable, editview, isEditing, uid, meta, viewPanelScene } = dashboard.useState(); + const { isPlaying } = playlistSrv.useState(); + + const isEditable = Boolean(editable); + const canSave = Boolean(meta.canSave); + const hasUid = Boolean(uid); + const isEditingDashboard = Boolean(isEditing); + const hasEditView = Boolean(editview); + const isEditingPanel = Boolean(editPanel); + const isViewingPanel = Boolean(viewPanelScene); + const isEditingLibraryPanel = isEditingPanel && isLibraryPanel(editPanel!.state.panelRef.resolve()); + const isShowingDashboard = !hasEditView && !isViewingPanel && !isEditingPanel; + const isEditingAndShowingDashboard = isEditingDashboard && isShowingDashboard; + const isSnapshot = Boolean(meta.isSnapshot); + const canSaveInFolder = contextSrv.hasEditPermissionInFolders; + + const showPanelButtons = isEditingPanel && !hasEditView && !isViewingPanel; + const showPlayButtons = isPlaying && isShowingDashboard && !isEditingDashboard; + const showShareButton = hasUid && !isSnapshot && !isPlaying; + + return ( + + {renderActionElements( + [ + { + key: 'play-list-previous-button', + component: PlayListPreviousButton, + group: 'playlist', + condition: showPlayButtons, + }, + { + key: 'play-list-stop-button', + component: PlayListStopButton, + group: 'playlist', + condition: showPlayButtons, + }, + { + key: 'play-list-next-button', + component: PlayListNextButton, + group: 'playlist', + condition: showPlayButtons, + }, + { + key: 'back-to-dashboard-button', + component: BackToDashboardButton, + group: 'panel', + condition: hasEditView || ((isViewingPanel || isEditingPanel) && !isEditingLibraryPanel), + }, + { + key: 'discard-panel-button', + component: DiscardPanelButton, + group: 'panel', + condition: showPanelButtons && !isEditingLibraryPanel, + }, + { + key: 'discard-library-panel-button', + component: DiscardLibraryPanelButton, + group: 'panel', + condition: showPanelButtons && isEditingLibraryPanel, + }, + { + key: 'unlink-library-panel-button', + component: UnlinkLibraryPanelButton, + group: 'panel', + condition: showPanelButtons && isEditingLibraryPanel, + }, + { + key: 'save-library-panel-button', + component: SaveLibraryPanelButton, + group: 'panel', + condition: showPanelButtons && isEditingLibraryPanel, + }, + { + key: 'edit-schema-v2-button', + component: EditSchemaV2Button, + group: 'dashboard', + condition: isEditingAndShowingDashboard && hasUid, + }, + { + key: 'dashboard-settings', + component: DashboardSettingsButton, + group: 'dashboard', + condition: isEditingAndShowingDashboard && dashboard.canEditDashboard(), + }, + { + key: 'save-dashboard', + component: SaveDashboard, + group: 'save-edit', + condition: isEditingDashboard && !isEditingLibraryPanel && (canSave || canSaveInFolder), + }, + { + key: 'make-dashboard-editable-button', + component: MakeDashboardEditableButton, + group: 'save-edit', + condition: !isEditing && dashboard.canEditDashboard() && !isViewingPanel && !isEditable, + }, + { + key: 'edit-dashboard-switch', + component: EditDashboardSwitch, + group: 'save-edit', + condition: dashboard.canEditDashboard() && !isEditingLibraryPanel && !isViewingPanel && isEditable, + }, + { + key: 'new-export-dashboard-button', + component: ExportDashboardButton, + group: 'export-share', + condition: showShareButton, + }, + { + key: 'new-share-dashboard-button', + component: ShareDashboardButton, + group: 'export-share', + condition: showShareButton, + }, + ], + dashboard + )} + + ); +}; + +const getStyles = () => ({ + container: css({ + flex: 1, + }), +}); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/ToolbarActionsNew.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/ToolbarActionsNew.tsx new file mode 100644 index 00000000000..f7d5a9b7784 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/ToolbarActionsNew.tsx @@ -0,0 +1,19 @@ +import { Stack } from '@grafana/ui'; + +import { DashboardScene } from '../DashboardScene'; + +import { LeftActions } from './LeftActions'; +import { RightActions } from './RightActions'; + +interface Props { + dashboard: DashboardScene; +} + +export function ToolbarActionsNew({ dashboard }: Props) { + return ( + + + + + ); +} diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/BackToDashboardButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/BackToDashboardButton.tsx new file mode 100644 index 00000000000..1bc9ba5920f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/BackToDashboardButton.tsx @@ -0,0 +1,22 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { locationService } from '@grafana/runtime'; +import { Button } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { ToolbarActionProps } from '../types'; + +export const BackToDashboardButton = ({ dashboard }: ToolbarActionProps) => ( + +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/DashboardSettingsButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/DashboardSettingsButton.tsx new file mode 100644 index 00000000000..917e0a02c0a --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/DashboardSettingsButton.tsx @@ -0,0 +1,14 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { Icon, ToolbarButton } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { ToolbarActionProps } from '../types'; + +export const DashboardSettingsButton = ({ dashboard }: ToolbarActionProps) => ( + } + onClick={() => dashboard.onOpenSettings()} + data-testid={selectors.components.NavToolbar.editDashboard.settingsButton} + /> +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardLibraryPanelButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardLibraryPanelButton.tsx new file mode 100644 index 00000000000..99d1397d11e --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardLibraryPanelButton.tsx @@ -0,0 +1,18 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { Button } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { ToolbarActionProps } from '../types'; + +export const DiscardLibraryPanelButton = ({ dashboard }: ToolbarActionProps) => ( + +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardPanelButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardPanelButton.tsx new file mode 100644 index 00000000000..a1deec24a08 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/DiscardPanelButton.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Button } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { PanelEditor } from '../../../panel-edit/PanelEditor'; +import { ToolbarActionProps } from '../types'; + +export const DiscardPanelButton = ({ dashboard }: ToolbarActionProps) => { + const isEditedPanelDirty = usePanelEditDirty(dashboard.state.editPanel); + + return ( + + ); +}; + +function usePanelEditDirty(panelEditor?: PanelEditor) { + const [isDirty, setIsDirty] = useState(); + + useEffect(() => { + if (panelEditor) { + const unsub = panelEditor.subscribeToState((state) => { + if (state.isDirty !== isDirty) { + setIsDirty(state.isDirty); + } + }); + + return () => unsub.unsubscribe(); + } + + return; + }, [panelEditor, isDirty]); + + return isDirty; +} diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx new file mode 100644 index 00000000000..b5ff59c9566 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.tsx @@ -0,0 +1,28 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { t } from 'app/core/internationalization'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; + +import { ToolbarActionProps } from '../types'; + +import { ToolbarSwitch } from './ToolbarSwitch'; + +export const EditDashboardSwitch = ({ dashboard }: ToolbarActionProps) => ( + { + evt.preventDefault(); + evt.stopPropagation(); + + if (!dashboard.state.isEditing) { + dashboard.onEnterEditMode(); + } else { + dashboard.exitEditMode({ skipConfirm: false }); + } + }} + /> +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditSchemaV2Button.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditSchemaV2Button.tsx new file mode 100644 index 00000000000..fc18a235bdc --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditSchemaV2Button.tsx @@ -0,0 +1,12 @@ +import { Icon, ToolbarButton } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { ToolbarActionProps } from '../types'; + +export const EditSchemaV2Button = ({ dashboard }: ToolbarActionProps) => ( + } + onClick={() => dashboard.openV2SchemaEditor()} + /> +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ExportDashboardButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ExportDashboardButton.tsx new file mode 100644 index 00000000000..4d361dfcdfb --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ExportDashboardButton.tsx @@ -0,0 +1,33 @@ +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { locationService } from '@grafana/runtime'; +import { t } from 'app/core/internationalization'; +import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; + +import ExportMenu from '../../../sharing/ExportButton/ExportMenu'; +import { DashboardInteractions } from '../../../utils/interactions'; +import { ToolbarActionProps } from '../types'; + +import { ShareExportDashboardButton } from './ShareExportDashboardButton'; + +const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton; + +export const ExportDashboardButton = ({ dashboard }: ToolbarActionProps) => ( + } + groupTestId={newExportButtonSelector.container} + buttonLabel={t('dashboard.toolbar.new.export.title', 'Export')} + buttonTooltip={t('dashboard.toolbar.new.export.tooltip', 'Export as JSON')} + buttonTestId={newExportButtonSelector.container} + onButtonClick={() => { + locationService.partial({ shareView: shareDashboardType.export }); + + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.export, + shareResource: getTrackingSource(), + }); + }} + arrowLabel={t('dashboard.toolbar.new.export.arrow', 'Export')} + arrowTestId={newExportButtonSelector.arrowMenu} + dashboard={dashboard} + /> +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.tsx new file mode 100644 index 00000000000..d544a6b9805 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.tsx @@ -0,0 +1,22 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { Button } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; + +import { ToolbarActionProps } from '../types'; + +export const MakeDashboardEditableButton = ({ dashboard }: ToolbarActionProps) => ( + +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ManagedDashboardBadge.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ManagedDashboardBadge.tsx new file mode 100644 index 00000000000..a795fa60a03 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ManagedDashboardBadge.tsx @@ -0,0 +1,31 @@ +import { Badge } from '@grafana/ui'; +import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types'; + +import { ToolbarActionProps } from '../types'; + +export const ManagedDashboardBadge = ({ dashboard }: ToolbarActionProps) => { + if (!dashboard.state.meta.k8s?.annotations) { + return null; + } + + let text = 'Provisioned'; + const kind = dashboard.state.meta.k8s.annotations[AnnoKeyManagerKind]; + const id = dashboard.state.meta.k8s.annotations[AnnoKeyManagerIdentity]; + + switch (kind) { + case ManagerKind.Terraform: + text = 'Terraform'; + break; + case ManagerKind.Kubectl: + text = 'Kubectl'; + break; + case ManagerKind.Plugin: + text = `Plugin: ${id}`; + break; + case ManagerKind.Repo: + text = 'Repository'; + break; + } + + return ; +}; diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx new file mode 100644 index 00000000000..c59d06a74ae --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx @@ -0,0 +1,6 @@ +import { GoToSnapshotOriginButton } from '../../GoToSnapshotOriginButton'; +import { ToolbarActionProps } from '../types'; + +export const OpenSnapshotOriginButton = ({ dashboard }: ToolbarActionProps) => ( + +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListNextButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListNextButton.tsx new file mode 100644 index 00000000000..de3c7583713 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListNextButton.tsx @@ -0,0 +1,16 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { ToolbarButton } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; + +import { ToolbarActionProps } from '../types'; + +export const PlayListNextButton = ({}: ToolbarActionProps) => ( + playlistSrv.next()} + narrow + /> +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListPreviousButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListPreviousButton.tsx new file mode 100644 index 00000000000..782253dc482 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListPreviousButton.tsx @@ -0,0 +1,15 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { ToolbarButton } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; + +import { ToolbarActionProps } from '../types'; + +export const PlayListPreviousButton = ({}: ToolbarActionProps) => ( + playlistSrv.prev()} + /> +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListStopButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListStopButton.tsx new file mode 100644 index 00000000000..0b8a0f10bac --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PlayListStopButton.tsx @@ -0,0 +1,15 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { ToolbarButton } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; + +import { ToolbarActionProps } from '../types'; + +export const PlayListStopButton = ({}: ToolbarActionProps) => ( + playlistSrv.stop()} + data-testid={selectors.pages.Dashboard.DashNav.playlistControls.stop} + > + Stop playlist + +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx new file mode 100644 index 00000000000..0194fb76d8f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/PublicDashboardBadge.tsx @@ -0,0 +1,28 @@ +import { css } from '@emotion/css'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Badge, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { ToolbarActionProps } from '../types'; + +export const PublicDashboardBadge = ({}: ToolbarActionProps) => { + const styles = useStyles2(getStyles); + + return ( + + ); +}; + +const getStyles = () => ({ + badge: css({ + color: 'grey', + backgroundColor: 'transparent', + border: '1px solid', + }), +}); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx new file mode 100644 index 00000000000..3600add803f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx @@ -0,0 +1,80 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { Button, ButtonGroup, Dropdown, Menu } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { ToolbarActionProps } from '../types'; +import { useIsManagedRepository } from '../utils'; + +export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => { + const { meta, isDirty, uid } = dashboard.state; + + const isNew = !Boolean(uid || dashboard.isManaged()); + const isManagedRepository = useIsManagedRepository(dashboard); + + // if we only can save + if (isNew) { + return ( + + ); + } + + // If we only can save as copy + if (contextSrv.hasEditPermissionInFolders && !meta.canSave && !meta.canMakeEditable && !isManagedRepository) { + return ( + + ); + } + + return ( + + + + dashboard.openSaveDrawer({})} + /> + dashboard.openSaveDrawer({ saveAsCopy: true })} + /> + + } + > + +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareDashboardButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareDashboardButton.tsx new file mode 100644 index 00000000000..8cacae1a4aa --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareDashboardButton.tsx @@ -0,0 +1,40 @@ +import { useAsyncFn } from 'react-use'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { t } from 'app/core/internationalization'; + +import ShareMenu from '../../../sharing/ShareButton/ShareMenu'; +import { buildShareUrl } from '../../../sharing/ShareButton/utils'; +import { DashboardInteractions } from '../../../utils/interactions'; +import { ToolbarActionProps } from '../types'; + +import { ShareExportDashboardButton } from './ShareExportDashboardButton'; + +const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton; + +export const ShareDashboardButton = ({ dashboard }: ToolbarActionProps) => { + const [_, buildUrl] = useAsyncFn(async () => { + DashboardInteractions.toolbarShareClick(); + return await buildShareUrl(dashboard); + }, [dashboard]); + + return ( + } + onMenuVisibilityChange={(isOpen) => { + if (isOpen) { + DashboardInteractions.toolbarShareDropdownClick(); + } + }} + groupTestId={newShareButtonSelector.shareLink} + buttonLabel={t('dashboard.toolbar.new.share.title', 'Share')} + buttonTooltip={t('dashboard.toolbar.new.share.tooltip', 'Copy link')} + buttonTestId={newShareButtonSelector.container} + onButtonClick={buildUrl} + arrowLabel={t('dashboard.toolbar.new.share.arrow', 'Share')} + arrowTestId={newShareButtonSelector.arrowMenu} + dashboard={dashboard} + variant={!dashboard.state.isEditing ? 'primary' : 'secondary'} + /> + ); +}; diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareExportDashboardButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareExportDashboardButton.tsx new file mode 100644 index 00000000000..bebed306fbe --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ShareExportDashboardButton.tsx @@ -0,0 +1,100 @@ +import { css } from '@emotion/css'; +import { ReactElement, useState } from 'react'; + +import { Button, ButtonGroup, Dropdown, useStyles2 } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { t } from 'app/core/internationalization'; +import { ShowConfirmModalEvent } from 'app/types/events'; + +import { ToolbarActionProps } from '../types'; + +interface Props extends ToolbarActionProps { + menu: ReactElement | (() => ReactElement); + onMenuVisibilityChange?: (isOpen: boolean) => void; + groupTestId: string; + buttonLabel: string; + buttonTooltip: string; + buttonTestId: string; + onButtonClick?: () => void; + arrowLabel: string; + arrowTestId: string; + variant?: 'primary' | 'secondary'; +} + +export const ShareExportDashboardButton = ({ + dashboard, + menu, + onMenuVisibilityChange, + groupTestId, + buttonLabel, + buttonTooltip, + buttonTestId, + onButtonClick, + arrowLabel, + arrowTestId, + variant = 'secondary', +}: Props) => { + const styles = useStyles2(getStyles); + const [isOpen, setIsOpen] = useState(false); + + return ( + { + if (dashboard.state.isEditing && dashboard.state.isDirty) { + evt.preventDefault(); + evt.stopPropagation(); + + appEvents.publish( + new ShowConfirmModalEvent({ + title: t('dashboard.toolbar.new.share-export.modal.title', 'Save changes to dashboard?'), + text: t( + 'dashboard.toolbar.new.share-export.modal.text', + 'You have unsaved changes to this dashboard. You need to save them before you can share it.' + ), + icon: 'exclamation-triangle', + noText: t('dashboard.toolbar.new.share-export.modal.noText', 'Discard'), + yesText: t('dashboard.toolbar.new.share-export.modal.yesText', 'Save'), + yesButtonVariant: 'primary', + onConfirm: () => dashboard.openSaveDrawer({}), + }) + ); + } + }} + > + + { + if (dashboard.state.isEditing && dashboard.state.isDirty) { + return; + } + + onMenuVisibilityChange?.(isOpen); + + setIsOpen(isOpen); + }} + > + +); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/types.ts b/public/app/features/dashboard-scene/scene/new-toolbar/types.ts new file mode 100644 index 00000000000..6fe9a200b92 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/types.ts @@ -0,0 +1,14 @@ +import { FC } from 'react'; + +import { DashboardScene } from '../DashboardScene'; + +export interface ToolbarAction { + key: string; + component: FC; + group: string; + condition: boolean; +} + +export interface ToolbarActionProps { + dashboard: DashboardScene; +} diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/utils.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/utils.tsx new file mode 100644 index 00000000000..0b44e415a75 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/new-toolbar/utils.tsx @@ -0,0 +1,62 @@ +import { ReactNode } from 'react'; + +import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { selectFolderRepository } from 'app/features/provisioning/utils/selectors'; +import { useSelector } from 'app/types'; + +import { DynamicDashNavButtonModel } from '../../utils/registerDynamicDashNavAction'; +import { DashboardScene } from '../DashboardScene'; + +import { ToolbarAction } from './types'; + +export function renderActionElements(toolbarActions: ToolbarAction[], dashboard: DashboardScene): ReactNode[] { + const actionElements: ReactNode[] = []; + let lastGroup = ''; + + for (const action of toolbarActions) { + if (!action.condition) { + continue; + } + + if (lastGroup && lastGroup !== action.group) { + actionElements.push(); + } + + actionElements.push(); + lastGroup = action.group; + } + + return actionElements; +} + +export function getDynamicActions( + registeredActions: DynamicDashNavButtonModel[], + group: string, + condition: boolean +): ToolbarAction[] { + const dashboard = getDashboardSrv().getCurrent()!; + + return registeredActions.reduce((acc, action) => { + const props = { dashboard }; + + if (!action.show(props)) { + return acc; + } + + acc.push({ + key: acc.length.toString(), + group, + condition, + component: () => , + }); + + return acc; + }, []); +} + +export function useIsManagedRepository(dashboard: DashboardScene): boolean { + const folderRepo = useSelector((state) => selectFolderRepository(state, dashboard.state.meta.folderUid)); + + return Boolean(dashboard.isManagedRepository() || folderRepo); +} diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx index 5fd21ab76c6..9c4aea1cda6 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportButton.tsx @@ -10,7 +10,11 @@ import ExportMenu from './ExportMenu'; const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton; -export default function ExportButton({ dashboard }: { dashboard: DashboardScene }) { +interface Props { + dashboard: DashboardScene; +} + +export default function ExportButton({ dashboard }: Props) { const [isOpen, setIsOpen] = useState(false); const onMenuClick = useCallback((isOpen: boolean) => { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 7527f9d0682..0f8c5c3fac9 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1537,7 +1537,6 @@ "label": "Edit", "tooltip": "Enter edit mode" }, - "edit-dashboard-v2-schema": "Edit dashboard v2 schema", "enter-edit-mode": { "label": "Make editable", "tooltip": "This dashboard was marked as read only" @@ -1548,6 +1547,66 @@ }, "mark-favorite": "Mark as favorite", "more-save-options": "More save options", + "new": { + "back-to-dashboard": "Back to dashboard", + "dashboard-settings": { + "tooltip": "Dashboard settings" + }, + "discard-library-panel-changes": "Discard library panel changes", + "discard-panel": "Discard panel changes", + "discard-panel-new": "Discard panel", + "edit-dashboard-v2-schema": { + "tooltip": "Edit dashboard v2 schema" + }, + "edit-toggle": { + "enter": { + "label": "Enter edit mode" + }, + "exit": { + "label": "Exit edit mode" + } + }, + "enter-edit-mode": { + "label": "Make editable", + "tooltip": "This dashboard was marked as read only" + }, + "export": { + "arrow": "Export", + "title": "Export", + "tooltip": "Export as JSON" + }, + "mark-favorite": "Mark as favorite", + "more-save-options": "More save options", + "playlist-next": "Go to next dashboard", + "playlist-previous": "Go to previous dashboard", + "playlist-stop": "Stop playlist", + "public-dashboard": "Public", + "save-dashboard": { + "label": "Save", + "tooltip": "Save changes" + }, + "save-dashboard-copy": { + "label": "Save as copy", + "tooltip": "Save as copy" + }, + "save-dashboard-short": "Save", + "save-library-panel": "Save library panel", + "share": { + "arrow": "Share", + "title": "Share", + "tooltip": "Copy link" + }, + "share-export": { + "modal": { + "noText": "Discard", + "text": "You have unsaved changes to this dashboard. You need to save them before you can share it.", + "title": "Save changes to dashboard?", + "yesText": "Save" + } + }, + "unlink-library-panel": "Unlink library panel", + "unmark-favorite": "Unmark as favorite" + }, "open-original": "Open original dashboard", "playlist-next": "Go to next dashboard", "playlist-previous": "Go to previous dashboard", @@ -1571,7 +1630,6 @@ "tooltip": "Share dashboard" }, "share-button": "Share", - "show-hidden-elements": "Show hidden", "switch-old-dashboard": "Switch to old dashboard page", "unlink-library-panel": "Unlink library panel", "unmark-favorite": "Unmark as favorite" From 41a2aa41f8891d786c58b1dbeef2cd775ebee8a9 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 20 Mar 2025 15:19:52 +0100 Subject: [PATCH 03/79] Alerting: Remove rule type switch for modified export mode (#102287) --- .../alert-rule-form/AlertRuleForm.tsx | 2 +- .../alert-rule-form/ModifyExportRuleForm.tsx | 2 +- .../QueryAndExpressionsStep.tsx | 24 ++++++++++++------- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index b99fcac732a..f68296b6fbb 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -320,7 +320,7 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => {/* Step 1 */} {/* Step 2 */} - + {/* Step 3-4-5 */} {showDataSourceDependantStep && ( <> diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index 8b867293622..32e4fd72f13 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -100,7 +100,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor {/* Step 1 */} {/* Step 2 */} - + {/* Step 3-4-5 */} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index 20d3359a03e..b32219a4fb6 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -77,9 +77,15 @@ import { useAlertQueryRunner } from './useAlertQueryRunner'; interface Props { editingExistingRule: boolean; onDataChange: (error: string) => void; + /** + * The mode of the rule editor. + * - 'edit' standard rule editor mode + * - 'draft' non-saveable form mode used for exporting to provisioning formats + */ + mode: 'edit' | 'draft'; } -export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: Props) => { +export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mode }: Props) => { const { setValue, getValues, @@ -515,12 +521,14 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P }} /> - + {mode === 'edit' && ( + + )} )} @@ -555,7 +563,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P )} {/* We only show Switch for Grafana managed alerts */} - {isGrafanaAlertingType && !simplifiedQueryStep && ( + {isGrafanaAlertingType && !simplifiedQueryStep && mode === 'edit' && ( Date: Thu, 20 Mar 2025 15:31:21 +0100 Subject: [PATCH 04/79] Alerting: Add rule_query_offset setting for Prometheus rule conversion (#102500) Adds a new configuration option to specify a time offset for rule evaluation, which gets applied and saved during the Prometheus -> Grafana conversion. For example: [unified_alerting.prometheus_conversion] rule_query_offset = 1m Changing this option affects only the rules imported after the change. If query_offset is set at the group level, it takes precedence over this setting. Default is set to 1m. --- conf/defaults.ini | 10 ++++++ conf/sample.ini | 10 ++++++ .../ngalert/api/api_convert_prometheus.go | 1 + pkg/services/ngalert/prom/convert_test.go | 35 +++++++++++++++++-- pkg/setting/setting_unified_alerting.go | 12 +++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 1061677bee4..55378a7f6ee 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1515,6 +1515,16 @@ max_age = # Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations. max_annotations_to_keep = +[unified_alerting.prometheus_conversion] +# Configuration options for converting Prometheus alerting and recording rules to Grafana rules. +# These settings affect rules created via the Prometheus conversion API. + +# Offset the rule evaluation time for imported rules by a specified duration in the past. +# This offset is applied and saved to the rule query during the conversion process from Prometheus to Grafana format. +# The setting only affects rules imported after the configuration change is made and does not modify existing rules. +# Accepts duration formats like: 30s, 1m, 1h. +rule_query_offset = 1m + [recording_rules] # Enable recording rules. You must provide write credentials below. enabled = false diff --git a/conf/sample.ini b/conf/sample.ini index 4cecf657e42..ea2cb9a00df 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1496,6 +1496,16 @@ max_age = # Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations. max_annotations_to_keep = +[unified_alerting.prometheus_conversion] +# Configuration options for converting Prometheus alerting and recording rules to Grafana rules. +# These settings affect rules created via the Prometheus conversion API. + +# Offset the rule evaluation time for imported rules by a specified duration in the past. +# This offset is applied and saved to the rule query during the conversion process from Prometheus to Grafana format. +# The setting only affects rules imported after the configuration change is made and does not modify existing rules. +# Accepts duration formats like: 30s, 1m, 1h. +rule_query_offset = 1m + #################################### Recording Rules ##################### [recording_rules] # Enable recording rules. You must provide write credentials below. diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index d9f28509622..5cbe398b459 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -463,6 +463,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup( IsPaused: pauseAlertRules, }, KeepOriginalRuleDefinition: util.Pointer(keepOriginalRuleDefinition), + EvaluationOffset: &srv.cfg.PrometheusConversion.RuleQueryOffset, }, ) if err != nil { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index dc4017f477e..7e0149ac14a 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -179,6 +179,32 @@ func TestPrometheusRulesToGrafana(t *testing.T) { }, expectError: false, }, + { + name: "when global query offset is set, it should be used", + orgID: 1, + namespace: "some-namespace-uid", + promGroup: PrometheusRuleGroup{ + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), + Rules: []PrometheusRule{ + { + Alert: "alert-1", + Expr: "cpu_usage > 80", + For: util.Pointer(prommodel.Duration(5 * time.Minute)), + Labels: map[string]string{ + "severity": "critical", + }, + Annotations: map[string]string{ + "summary": "CPU usage is critical", + }, + }, + }, + }, + config: Config{ + EvaluationOffset: util.Pointer(5 * time.Minute), + }, + expectError: false, + }, } for _, tc := range testCases { @@ -244,8 +270,13 @@ func TestPrometheusRulesToGrafana(t *testing.T) { require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name) require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name) - require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To) - require.Equal(t, models.Duration(10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From) + + evalOffset := time.Duration(0) + if tc.config.EvaluationOffset != nil { + evalOffset = *tc.config.EvaluationOffset + } + require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To) + require.Equal(t, models.Duration(evalOffset+10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From) originalRuleDefinition, err := yaml.Marshal(promRule) require.NoError(t, err) diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index b14d22b97bc..84461d3c82f 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -112,6 +112,7 @@ type UnifiedAlertingSettings struct { StateHistory UnifiedAlertingStateHistorySettings RemoteAlertmanager RemoteAlertmanagerSettings RecordingRules RecordingRuleSettings + PrometheusConversion UnifiedAlertingPrometheusConversionSettings // MaxStateSaveConcurrency controls the number of goroutines (per rule) that can save alert state in parallel. MaxStateSaveConcurrency int @@ -165,6 +166,12 @@ type UnifiedAlertingReservedLabelSettings struct { DisabledLabels map[string]struct{} } +// UnifiedAlertingPrometheusConversionSettings contains configuration for converting Prometheus rules to Grafana format +type UnifiedAlertingPrometheusConversionSettings struct { + // RuleQueryOffset defines a time offset to apply to rule queries during conversion from Prometheus to Grafana format + RuleQueryOffset time.Duration +} + type UnifiedAlertingStateHistorySettings struct { Enabled bool Backend string @@ -437,6 +444,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { } uaCfg.StateHistory = uaCfgStateHistory + prometheusConversion := iniFile.Section("unified_alerting.prometheus_conversion") + uaCfg.PrometheusConversion = UnifiedAlertingPrometheusConversionSettings{ + RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute), + } + rr := iniFile.Section("recording_rules") uaCfgRecordingRules := RecordingRuleSettings{ Enabled: rr.Key("enabled").MustBool(false), From 58eabf8ad6b7fd1cadd46d9c35028216f12d40c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Thu, 20 Mar 2025 15:32:52 +0100 Subject: [PATCH 05/79] fix(unified-storage): inherit parent context with auth for background ops (#102537) --- pkg/storage/legacysql/dualwrite/dualwriter.go | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index c34609cc9f5..7e3c955fa26 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -45,13 +45,12 @@ func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOp // Once we have successfully read from legacy, we can check if we want to fail on a unified read. // If we allow the unified read to fail, we can do it in the background. if d.errorIsOK { - go func() { - ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout) + go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.Get(ctxBg, name, options); err != nil { d.log.Error("failed background GET to unified", "err", err) } - }() + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return legacyGet, nil } // If it's not okay to fail, we have to check it in the foreground. @@ -75,13 +74,12 @@ func (d *dualWriter) List(ctx context.Context, options *metainternalversion.List // Once we have successfully listed from legacy, we can check if we want to fail on a unified list. // If we allow the unified list to fail, we can do it in the background and return. if d.errorIsOK { - go func() { - ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout) + go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.List(ctxBg, options); err != nil { d.log.Error("failed background LIST to unified", "err", err) } - }() + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return legacyList, nil } // If it's not okay to fail, we have to check it in the foreground. @@ -139,13 +137,12 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida return storageObj, nil } else if d.errorIsOK { // If we don't use unified as the primary store and errors are okay, let's create it in the background. - go func() { - ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout) + go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.Create(ctxBg, createdCopy, createValidation, options); err != nil { log.Error("unable to create object in unified storage", "err", err) } - }() + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) } else { // Otherwise let's create it in the foreground and return any error. if _, err := d.unified.Create(ctx, createdCopy, createValidation, options); err != nil { @@ -185,14 +182,13 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r return objFromStorage, asyncStorage, nil } else if d.errorIsOK { // If errors are okay and unified is not primary, we can just run it as background operation. - go func() { - ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout) + go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() _, _, err := d.unified.Delete(ctxBg, name, deleteValidation, options) if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK { d.log.Error("failed background DELETE in unified storage", "err", err) } - }() + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) } // Otherwise we just run it in the foreground and return an error if any might happen. _, _, err = d.unified.Delete(ctx, name, deleteValidation, options) @@ -226,13 +222,12 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat return d.unified.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options) } else if d.errorIsOK { // If unified is not primary, but errors are okay, we can just run in the background. - go func() { - ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout) + go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, _, err := d.unified.Update(ctxBg, name, objInfo, createValidation, updateValidation, forceAllowCreate, options); err != nil { log.Error("failed background UPDATE to unified storage", "err", err) } - }() + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return objFromLegacy, createdLegacy, nil } // If we want to check unified errors just run it in foreground. @@ -263,13 +258,12 @@ func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest return d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions) } else if d.errorIsOK { // If unified storage is not the primary store and errors are okay, we can just run it in the background. - go func() { - ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout) + go func(ctxBg context.Context, cancel context.CancelFunc) { defer cancel() if _, err := d.unified.DeleteCollection(ctxBg, deleteValidation, options, listOptions); err != nil { log.Error("failed background DELETE collection to unified storage", "err", err) } - }() + }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout)) return deletedLegacy, nil } // Otherwise we have to check the error and run it in the foreground. From 7411472f52408dd285c8102c3601674dfbbcc038 Mon Sep 17 00:00:00 2001 From: Jo Date: Thu, 20 Mar 2025 16:12:29 +0100 Subject: [PATCH 06/79] Docs: Make role definitions more precise (#102391) * fix missing clarification * remove OnCall admonitions --- .../rbac-fixed-basic-role-definitions/index.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 8458e4d9355..7115a4e12ec 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -50,7 +50,7 @@ refs: Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud). {{% /admonition %}} -The following tables list permissions associated with basic and fixed roles. +The following tables list permissions associated with basic and fixed roles. This does not include basic role assignments added by plugins or apps. ## Basic role assignments @@ -165,16 +165,7 @@ There is only one exclusion at this moment. Role `fixed:alerting.provisioning:wr For more information about the permissions required to access alert rules, refer to [Create a custom role to access alerts in a folder](ref:plan-rbac-rollout-strategy-create-a-custom-role-to-access-alerts-in-a-folder). -### Grafana OnCall roles (beta) - -{{% admonition type="note" %}} -Available from Grafana 9.4 in early access. -{{% /admonition %}} - -{{% admonition type="note" %}} -This feature is behind the `accessControlOnCall` feature toggle. -You can enable feature toggles through configuration file or environment variables. See configuration [docs](/docs/grafana//setup-grafana/configure-grafana/#feature_toggles) for details. -{{% /admonition %}} +### Grafana OnCall roles If you are using [Grafana OnCall](ref:oncall), you can try out the integration between Grafana OnCall and RBAC. For a detailed list of the available OnCall RBAC roles, refer to the table in [Available Grafana OnCall RBAC roles and granted actions](ref:available-grafana-oncall-rbac-roles--granted-actions). From 7970f0c79f053ef6ad94ceae173ad5fbcaf7be5b Mon Sep 17 00:00:00 2001 From: Jo Date: Thu, 20 Mar 2025 16:31:29 +0100 Subject: [PATCH 07/79] Docs: Fix broken dependency documentation (#101631) --- contribute/developer-guide.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index da3669615ab..6084df4ce66 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -19,8 +19,13 @@ We recommend using [Homebrew](https://brew.sh/) for installing any missing depen brew install git brew install go brew install node@22 -brew install corepack +``` + +In the repository enable and install yarn via corepack + +``` corepack enable +corepack install ``` ### Windows From 9ad7fef4f49f3af8317a87dfa9c4b5c5d385c92a Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Thu, 20 Mar 2025 16:46:13 +0100 Subject: [PATCH 08/79] Alerting: Simplified alert rule toggle bug fixes (#102119) --- .betterer.results | 5 +- .../components/rule-editor/QueryRows.tsx | 14 +++- .../QueryAndExpressionsStep.tsx | 30 +++++-- .../SimpleCondition.tsx | 30 ++----- ...riesTransformableToSimpleCondition.test.ts | 65 +++++++++++---- .../__snapshots__/reducer.test.tsx.snap | 31 ++++++- .../reducer.test.tsx | 14 ++-- .../query-and-alert-condition/reducer.ts | 73 +++++++---------- .../components/rule-editor/util.test.ts | 5 +- public/app/features/alerting/unified/mocks.ts | 28 ++++--- .../unified/rule-editor/formDefaults.test.ts | 10 ++- .../unified/rule-editor/formProcessing.ts | 77 +++++++++++------- .../alerting/unified/utils/rule-form.test.ts | 81 ++++++++++++++++++- .../alerting/unified/utils/rule-form.ts | 27 ++++--- .../expressions/utils/expressionTypes.ts | 19 ++++- 15 files changed, 339 insertions(+), 170 deletions(-) diff --git a/.betterer.results b/.betterer.results index bba659d251f..f74ac1484c1 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1870,11 +1870,12 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] ], "public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] ], "public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx index 08c67ebf3d2..4e951c5110a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx @@ -14,6 +14,7 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { Button, Card, Icon, Stack } from '@grafana/ui'; import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; +import { isExpressionQuery } from 'app/features/expressions/guards'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; @@ -236,8 +237,10 @@ function copyModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit } function newModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit { - const isInstant = getInstantFromDataQuery(item.model, settings.type); - return { + const isExpression = isExpressionQuery(item); + const isInstant = isExpression ? false : getInstantFromDataQuery(item); + + const newQuery: Omit = { refId: item.refId, relativeTimeRange: item.relativeTimeRange, queryType: '', @@ -246,9 +249,14 @@ function newModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit< refId: item.refId, hide: false, datasource: getDataSourceRef(settings), - instant: isInstant, }, }; + + if (isInstant && !isExpressionQuery(item)) { + (newQuery as AlertQuery).model.instant = isInstant; + } + + return newQuery; } interface DatasourceNotFoundProps { diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index b32219a4fb6..463401721a0 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -23,7 +23,12 @@ import { } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types'; +import { + ExpressionDatasourceUID, + ExpressionQuery, + ExpressionQueryType, + expressionTypes, +} from 'app/features/expressions/types'; import { AlertQuery } from 'app/types/unified-alerting-dto'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; @@ -50,7 +55,7 @@ import { RuleEditorSection } from '../RuleEditorSection'; import { errorFromCurrentCondition, errorFromPreviewData, findRenamedDataQueryReferences, refIdExists } from '../util'; import { CloudDataSourceSelector } from './CloudDataSourceSelector'; -import { SimpleConditionEditor, SimpleConditionIdentifier, getSimpleConditionFromExpressions } from './SimpleCondition'; +import { SimpleConditionEditor, getSimpleConditionFromExpressions } from './SimpleCondition'; import { SmartAlertTypeDetector } from './SmartAlertTypeDetector'; import { DESCRIPTIONS } from './descriptions'; import { @@ -146,7 +151,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod ); const simplifiedQueryStep = - isSwitchModeEnabled && isGrafanaAlertingType ? getValues('editorSettings.simplifiedQueryEditor') : false; + isSwitchModeEnabled && isGrafanaAlertingType ? editorSettings?.simplifiedQueryEditor : false; // If we switch to simple mode we need to update the simple condition with the data in the queries reducer useEffect(() => { @@ -164,15 +169,22 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod // Grafana Managed rules and recording rules do return; } - // we need to be sure the condition is set once we switch to simple mode + if (simplifiedQueryStep) { - setValue('condition', SimpleConditionIdentifier.thresholdId); - runQueries(getValues('queries'), SimpleConditionIdentifier.thresholdId); + const lastExpression = expressionQueries.at(-1); + if (!lastExpression) { + return; + } + + const condition = lastExpression.refId; + // we need to be sure the condition is set once we switch to simple mode + setValue('condition', condition); + runQueries(getValues('queries'), condition); } else { runQueries(getValues('queries'), condition || (getValues('condition') ?? '')); } }, - [isCloudAlertRuleType, runQueries, getValues, simplifiedQueryStep, setValue] + [isCloudAlertRuleType, expressionQueries, simplifiedQueryStep, setValue, runQueries, getValues] ); // whenever we update the queries we have to update the form too @@ -247,7 +259,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod // As a workaround we update form values as soon as possible to avoid stale state // This way we can access up to date queries in runQueriesPreview without waiting for re-render const previousQueries = getValues('queries'); - const expressionQueries = previousQueries.filter((query) => isExpressionQuery(query.model)); + + const expressionQueries = previousQueries.filter>(isExpressionQueryInAlert); + setValue('queries', [...updatedQueries, ...expressionQueries], { shouldValidate: false }); updateExpressionAndDatasource(updatedQueries); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx index acd8402694d..0fd8b0d8760 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx @@ -17,11 +17,6 @@ import { ExpressionResult } from '../../expressions/Expression'; import { updateExpression } from './reducer'; -export const SimpleConditionIdentifier = { - queryId: 'A', - reducerId: 'B', - thresholdId: 'C', -} as const; export interface SimpleCondition { whenField?: string; evaluator: { @@ -158,10 +153,8 @@ function updateReduceExpression( expressionQueriesList: Array>, dispatch: Dispatch ) { - const reduceExpression = expressionQueriesList.find( - (query) => - query.model.type === ExpressionQueryType.reduce && query.model.refId === SimpleConditionIdentifier.reducerId - ); + // 1. make sure have have a reduce expression and that it is pointing to the data query + const reduceExpression = expressionQueriesList.find((query) => query.model.type === ExpressionQueryType.reduce); const newReduceExpression = reduceExpression ? produce(reduceExpression?.model, (draft) => { @@ -179,10 +172,7 @@ function updateThresholdFunction( expressionQueriesList: Array>, dispatch: Dispatch ) { - const thresholdExpression = expressionQueriesList.find( - (query) => - query.model.type === ExpressionQueryType.threshold && query.model.refId === SimpleConditionIdentifier.thresholdId - ); + const thresholdExpression = expressionQueriesList.find((query) => query.model.type === ExpressionQueryType.threshold); const newThresholdExpression = produce(thresholdExpression, (draft) => { if (draft && draft.model.conditions) { @@ -198,10 +188,7 @@ function updateThresholdValue( expressionQueriesList: Array>, dispatch: Dispatch ) { - const thresholdExpression = expressionQueriesList.find( - (query) => - query.model.type === ExpressionQueryType.threshold && query.model.refId === SimpleConditionIdentifier.thresholdId - ); + const thresholdExpression = expressionQueriesList.find((query) => query.model.type === ExpressionQueryType.threshold); const newThresholdExpression = produce(thresholdExpression, (draft) => { if (draft && draft.model.conditions) { @@ -212,13 +199,8 @@ function updateThresholdValue( } export function getSimpleConditionFromExpressions(expressions: Array>): SimpleCondition { - const reduceExpression = expressions.find( - (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId - ); - const thresholdExpression = expressions.find( - (query) => - query.model.type === ExpressionQueryType.threshold && query.refId === SimpleConditionIdentifier.thresholdId - ); + const reduceExpression = expressions.find((query) => query.model.type === ExpressionQueryType.reduce); + const thresholdExpression = expressions.find((query) => query.model.type === ExpressionQueryType.threshold); const conditionsFromThreshold = thresholdExpression?.model.conditions ?? []; const whenField = reduceExpression?.model.reducer; const params = conditionsFromThreshold[0]?.evaluator?.params diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts index 2a9294ada7d..96be5b02dc5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts @@ -1,35 +1,50 @@ import { produce } from 'immer'; import { EvalFunction } from 'app/features/alerting/state/alertDef'; -import { dataQuery, reduceExpression, thresholdExpression } from 'app/features/alerting/unified/mocks'; +import { + mockDataQuery, + mockDataSource, + mockReduceExpression, + mockThresholdExpression, +} from 'app/features/alerting/unified/mocks'; import { areQueriesTransformableToSimpleCondition } from 'app/features/alerting/unified/rule-editor/formProcessing'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import { ExpressionQuery, ReducerMode } from 'app/features/expressions/types'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; -const expressionQueries: Array> = [reduceExpression, thresholdExpression]; +const reduceExpression = mockReduceExpression({ expression: 'A', settings: { mode: ReducerMode.Strict } }); +const thresholdExpression = mockThresholdExpression({ expression: 'B' }); +const expressionQueries: Array> = [reduceExpression, thresholdExpression]; +const ds = mockDataSource({ type: DataSourceType.Prometheus, name: 'Mimir-cloud', uid: 'abc123' }); describe('areQueriesTransformableToSimpleCondition', () => { + beforeEach(() => { + setupDataSources(ds); + }); it('should return false if dataQueries length is not 1', () => { // zero dataQueries expect(areQueriesTransformableToSimpleCondition([], expressionQueries)).toBe(false); // more than one dataQueries - expect(areQueriesTransformableToSimpleCondition([dataQuery, dataQuery], expressionQueries)).toBe(false); + expect(areQueriesTransformableToSimpleCondition([mockDataQuery(), mockDataQuery()], expressionQueries)).toBe(false); }); + it('should return false if expressionQueries length is not 2', () => { - const dataQueries: Array> = [dataQuery]; + const dataQueries: Array> = [mockDataQuery()]; const result = areQueriesTransformableToSimpleCondition(dataQueries, []); expect(result).toBe(false); }); - it('should return false if the dataQuery refId does not match SimpleConditionIdentifier.queryId', () => { - const dataQueries: Array> = [ - { refId: 'notSimpleCondition', datasourceUid: 'abc123', queryType: '', model: { refId: 'notSimpleCondition' } }, - ]; + // notSimpleCondition + // reducer: + it('should return false if the mockDataQuery() refId does not match SimpleConditionIdentifier.queryId', () => { + const dataQueries: Array> = [mockDataQuery({ refId: 'foo' })]; const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); expect(result).toBe(false); }); + it('should return false if no reduce expression is found with correct type and refId', () => { - const dataQueries: Array> = [dataQuery]; + const dataQueries: Array> = [mockDataQuery()]; const result = areQueriesTransformableToSimpleCondition(dataQueries, [ { ...reduceExpression, refId: 'hello' }, thresholdExpression, @@ -37,17 +52,25 @@ describe('areQueriesTransformableToSimpleCondition', () => { expect(result).toBe(false); }); - it('should return false if no threshold expression is found with correct type and refId', () => { - const dataQueries: Array> = [dataQuery]; + it('should return false if no threshold expression is found that points to reducer', () => { + const dataQueries: Array> = [mockDataQuery()]; const result = areQueriesTransformableToSimpleCondition(dataQueries, [ reduceExpression, - { ...thresholdExpression, refId: 'hello' }, + mockThresholdExpression({ expression: 'hello' }), + ]); + expect(result).toBe(false); + }); + + it('should return false if no threshold expression is found that points to instant data query', () => { + const dataQueries: Array> = [mockDataQuery({ instant: true })]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + mockThresholdExpression({ expression: 'hello' }), ]); expect(result).toBe(false); }); it('should return false if reduceExpression settings mode is not ReducerMode.Strict', () => { - const dataQueries: Array> = [dataQuery]; + const dataQueries: Array> = [mockDataQuery()]; const transformedReduceExpression = produce(reduceExpression, (draft) => { draft.model.settings = { mode: ReducerMode.DropNonNumbers }; }); @@ -60,7 +83,7 @@ describe('areQueriesTransformableToSimpleCondition', () => { }); it('should return false if thresholdExpression unloadEvaluator has a value', () => { - const dataQueries: Array> = [dataQuery]; + const dataQueries: Array> = [mockDataQuery()]; const transformedThresholdExpression = produce(thresholdExpression, (draft) => { draft.model.conditions = [ @@ -79,9 +102,17 @@ describe('areQueriesTransformableToSimpleCondition', () => { ]); expect(result).toBe(false); }); - it('should return true when all conditions are met', () => { - const dataQueries: Array> = [dataQuery]; - const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); + + it('should return true when data query is connected to valid reducer and threshold', () => { + const result = areQueriesTransformableToSimpleCondition([mockDataQuery({ refId: 'A' })], expressionQueries); + expect(result).toBe(true); + }); + + it('should return true when all conditions are met for instant data query with threshold', () => { + const result = areQueriesTransformableToSimpleCondition( + [mockDataQuery({ instant: true })], + [mockThresholdExpression({ expression: 'A' })] + ); expect(result).toBe(true); }); }); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap index a316611bf55..292a6904f80 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap @@ -130,16 +130,16 @@ exports[`Query and expressions reducer should add reduce expression if there is }, "expression": "A", "reducer": "last", - "refId": "B", + "refId": "reducer", "type": "reduce", }, "queryType": "expression", - "refId": "B", + "refId": "reducer", }, { "datasourceUid": "__expr__", "model": { - "expression": "B", + "expression": "reducer", "refId": "C", "type": "threshold", }, @@ -218,6 +218,31 @@ exports[`Query and expressions reducer should remove first reducer 1`] = ` } `; +exports[`Query and expressions reducer should remove reducer even if reducer is not the first expression 1`] = ` +{ + "queries": [ + { + "datasourceUid": "abc123", + "model": { + "refId": "A", + }, + "queryType": "query", + "refId": "A", + }, + { + "datasourceUid": "__expr__", + "model": { + "expression": "A", + "refId": "C", + "type": "threshold", + }, + "queryType": "expression", + "refId": "C", + }, + ], +} +`; + exports[`Query and expressions reducer should rewire expressions 1`] = ` { "queries": [ diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx index 2a112e089c3..73372e12a57 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx @@ -10,7 +10,6 @@ import { import { defaultCondition } from 'app/features/expressions/utils/expressionTypes'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -import { SimpleConditionIdentifier } from './SimpleCondition'; import { QueriesAndExpressionsState, addNewDataQuery, @@ -28,22 +27,23 @@ import { } from './reducer'; const reduceExpression: AlertQuery = { - refId: SimpleConditionIdentifier.reducerId, + refId: 'B', queryType: 'expression', datasourceUid: '__expr__', model: { type: ExpressionQueryType.reduce, - refId: SimpleConditionIdentifier.reducerId, + refId: 'B', settings: { mode: ReducerMode.Strict }, + expression: 'A', }, }; const thresholdExpression: AlertQuery = { - refId: SimpleConditionIdentifier.thresholdId, + refId: 'C', queryType: 'expression', datasourceUid: '__expr__', model: { type: ExpressionQueryType.threshold, - refId: SimpleConditionIdentifier.thresholdId, + refId: 'C', }, }; @@ -400,7 +400,7 @@ describe('Query and expressions reducer', () => { expect(newState).toMatchSnapshot(); }); - it('should not remove first reducer if reducer is not the first expression', () => { + it('should remove reducer even if reducer is not the first expression', () => { const initialState: QueriesAndExpressionsState = { queries: [alertQuery, thresholdExpression, reduceExpression], }; @@ -412,7 +412,7 @@ describe('Query and expressions reducer', () => { expressionQueries: [thresholdExpression, reduceExpression], }) ); - expect(newState).toEqual(initialState); + expect(newState).toMatchSnapshot(); }); it('should not remove first reducer if reducer is not the second query', () => { diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts index aab9e184a56..b3499b8c520 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts @@ -8,21 +8,25 @@ import { getNextRefId, rangeUtil, } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { isExpressionQuery } from 'app/features/expressions/guards'; import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; -import { defaultCondition } from 'app/features/expressions/utils/expressionTypes'; +import { + defaultCondition, + isReducerExpression, + isThresholdExpression, +} from 'app/features/expressions/utils/expressionTypes'; import { AlertQuery } from 'app/types/unified-alerting-dto'; import { logError } from '../../../Analytics'; -import { DataSourceType, getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; +import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; import { getDefaultQueries, getInstantFromDataQuery } from '../../../utils/rule-form'; import { createDagFromQueries, getOriginOfRefId } from '../dag'; import { queriesWithUpdatedReferences, refIdExists } from '../util'; -import { SimpleConditionIdentifier } from './SimpleCondition'; +// this one will be used as the refID when we create a new reducer for the threshold expression +export const NEW_REDUCER_REF = 'reducer'; export interface QueriesAndExpressionsState { queries: AlertQuery[]; @@ -64,9 +68,10 @@ export const updateMaxDataPoints = createAction<{ refId: string; maxDataPoints: export const updateMinInterval = createAction<{ refId: string; minInterval: string }>('updateMinInterval'); export const resetToSimpleCondition = createAction('resetToSimpleCondition'); -export const optimizeReduceExpression = createAction<{ updatedQueries: AlertQuery[]; expressionQueries: AlertQuery[] }>( - 'optimizeReduceExpression' -); +export const optimizeReduceExpression = createAction<{ + updatedQueries: AlertQuery[]; + expressionQueries: Array>; +}>('optimizeReduceExpression'); export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: AlertQuery[]; expression: string }>( 'setRecordingRulesQueries' ); @@ -231,6 +236,7 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder .addCase(rewireExpressions, (state, { payload }) => { state.queries = queriesWithUpdatedReferences(state.queries, payload.oldRefId, payload.newRefId); }) + // removes the reduce expression when we have a instant data query .addCase(optimizeReduceExpression, (state, { payload }) => { const { updatedQueries, expressionQueries } = payload; @@ -239,48 +245,29 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder return; } - //sometimes we dont have data source in the model yet - const getDataSourceSettingsForFirstQuery = getDataSourceSrv().getInstanceSettings( - updatedQueries[0].datasourceUid - ); - - if (!getDataSourceSettingsForFirstQuery) { - return; - } - const type = getDataSourceSettingsForFirstQuery?.type; - - const firstQueryIsPromOrLoki = type === DataSourceType.Prometheus || type === DataSourceType.Loki; - - const isInstant = getInstantFromDataQuery(updatedQueries[0].model, type); - - const shouldRemoveReducer = - firstQueryIsPromOrLoki && updatedQueries.length === 1 && isInstant && expressionQueries.length === 2; - - const onlyOneExpressionNotReducer = - expressionQueries.length === 1 && - 'type' in expressionQueries[0].model && - expressionQueries[0].model.type !== ExpressionQueryType.reduce; - - // we only add the reduce expression if we have one data query and one expression query. For other cases we don't do anything, - // and let the user add the reducer manually. - const shouldAddReduceExpression = - firstQueryIsPromOrLoki && updatedQueries.length === 1 && !isInstant && onlyOneExpressionNotReducer; + const dataQuery = updatedQueries.at(0); + const isInstantDataQuery = dataQuery ? getInstantFromDataQuery(dataQuery) : false; + const shouldRemoveReducer = isInstantDataQuery && expressionQueries.length === 2; if (shouldRemoveReducer) { const reduceExpressionIndex = state.queries.findIndex( - (query) => isExpressionQuery(query.model) && query.model.type === ExpressionQueryType.reduce + (query) => + isExpressionQuery(query.model) && + isReducerExpression(query.model) && + query.model.expression === dataQuery?.refId ); - if (reduceExpressionIndex === 1) { - // means the reduce expression is the second query - state.queries.splice(reduceExpressionIndex, 1); - state.queries[1].model.expression = SimpleConditionIdentifier.queryId; - } + state.queries.splice(reduceExpressionIndex, 1); + state.queries[1].model.expression = dataQuery?.refId; } + + const shouldAddReduceExpression = + !isInstantDataQuery && expressionQueries.length === 1 && isThresholdExpression(expressionQueries[0].model); if (shouldAddReduceExpression) { // add reducer to the second position // we only update the refid and the model to point to the reducer expression - state.queries[1].model.expression = SimpleConditionIdentifier.reducerId; + state.queries[1].model.expression = NEW_REDUCER_REF; + // insert in second position the reducer expression state.queries.splice(1, 0, { datasourceUid: ExpressionDatasourceUID, @@ -288,10 +275,10 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder type: ExpressionQueryType.reduce, reducer: ReducerID.last, conditions: [{ ...defaultCondition, query: { params: [] } }], - expression: SimpleConditionIdentifier.queryId, - refId: SimpleConditionIdentifier.reducerId, + expression: dataQuery?.refId, + refId: NEW_REDUCER_REF, }), - refId: SimpleConditionIdentifier.reducerId, + refId: NEW_REDUCER_REF, queryType: 'expression', }); } diff --git a/public/app/features/alerting/unified/components/rule-editor/util.test.ts b/public/app/features/alerting/unified/components/rule-editor/util.test.ts index 680367f38ec..13a4a6480b5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.test.ts @@ -2,6 +2,7 @@ import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWi import { ClassicCondition, ExpressionQuery } from 'app/features/expressions/types'; import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { NEW_REDUCER_REF } from './query-and-alert-condition/reducer'; import { containsPathSeparator, findRenamedDataQueryReferences, @@ -163,10 +164,10 @@ describe('rule-editor', () => { it('should rewire threshold expressions', () => { const queries: AlertQuery[] = [dataSource, reduceExpression, thresholdExpression]; - const rewiredQueries = queriesWithUpdatedReferences(queries, 'B', 'REDUCER'); + const rewiredQueries = queriesWithUpdatedReferences(queries, 'B', NEW_REDUCER_REF); const queryModel = rewiredQueries[2].model as ExpressionQuery; - expect(queryModel.expression).toBe('REDUCER'); + expect(queryModel.expression).toBe(NEW_REDUCER_REF); }); it('should rewire multiple expressions', () => { diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 8e2701e0e04..4e0cd678109 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -56,7 +56,6 @@ import { import { DashboardSearchItem, DashboardSearchItemType } from '../../search/types'; -import { SimpleConditionIdentifier } from './components/rule-editor/query-and-alert-condition/SimpleCondition'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers'; @@ -772,30 +771,33 @@ export function mockDashboardDto( }; } -export const dataQuery: AlertQuery = { - refId: SimpleConditionIdentifier.queryId, +export const mockDataQuery = (partial: Partial = {}): AlertQuery => ({ + refId: partial?.refId ?? 'A', datasourceUid: 'abc123', queryType: '', - model: { refId: SimpleConditionIdentifier.queryId }, -}; + model: { refId: 'A', ...partial }, +}); -export const reduceExpression: AlertQuery = { - refId: SimpleConditionIdentifier.reducerId, +export const mockReduceExpression = (partial: Partial = {}): AlertQuery => ({ + refId: 'B', queryType: 'expression', datasourceUid: '__expr__', model: { type: ExpressionQueryType.reduce, - refId: SimpleConditionIdentifier.reducerId, + refId: 'B', settings: { mode: ReducerMode.Strict }, reducer: ReducerID.last, + ...partial, }, -}; -export const thresholdExpression: AlertQuery = { - refId: SimpleConditionIdentifier.thresholdId, +}); + +export const mockThresholdExpression = (partial: Partial = {}): AlertQuery => ({ + refId: 'C', queryType: 'expression', datasourceUid: '__expr__', model: { type: ExpressionQueryType.threshold, - refId: SimpleConditionIdentifier.thresholdId, + refId: 'C', + ...partial, }, -}; +}); diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts index 800b95a6193..9dd60a76b8d 100644 --- a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts +++ b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts @@ -1,6 +1,6 @@ import { config } from '@grafana/runtime'; -import { mockAlertQuery, mockDataSource, reduceExpression, thresholdExpression } from '../mocks'; +import { mockAlertQuery, mockDataSource, mockReduceExpression, mockThresholdExpression } from '../mocks'; import { testWithFeatureToggles } from '../test/test-utils'; import { RuleFormType } from '../types/rule-form'; import { Annotation } from '../utils/constants'; @@ -73,7 +73,11 @@ describe('formValuesFromQueryParams', () => { it('should enable simplified query editor if queries are transformable to simple condition', () => { const result = formValuesFromQueryParams( JSON.stringify({ - queries: [mockAlertQuery(), reduceExpression, thresholdExpression], + queries: [ + mockAlertQuery(), + mockReduceExpression({ expression: 'A' }), + mockThresholdExpression({ expression: 'B' }), + ], }), RuleFormType.grafana ); @@ -85,7 +89,7 @@ describe('formValuesFromQueryParams', () => { it('should disable simplified query editor if queries are not transformable to simple condition', () => { const result = formValuesFromQueryParams( JSON.stringify({ - queries: [mockAlertQuery(), mockAlertQuery(), thresholdExpression], + queries: [mockAlertQuery(), mockAlertQuery(), mockThresholdExpression({ expression: 'B' })], }), RuleFormType.grafana ); diff --git a/public/app/features/alerting/unified/rule-editor/formProcessing.ts b/public/app/features/alerting/unified/rule-editor/formProcessing.ts index c9fa1951417..d9f79892ce5 100644 --- a/public/app/features/alerting/unified/rule-editor/formProcessing.ts +++ b/public/app/features/alerting/unified/rule-editor/formProcessing.ts @@ -1,14 +1,15 @@ -import { omit } from 'lodash'; +import { isEmpty, omit } from 'lodash'; import { config } from '@grafana/runtime'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types'; +import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; +import { isStrictReducer } from 'app/features/expressions/utils/expressionTypes'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; -import { SimpleConditionIdentifier } from '../components/rule-editor/query-and-alert-condition/SimpleCondition'; import { KVObject, RuleFormValues } from '../types/rule-form'; import { defaultAnnotations } from '../utils/constants'; import { DataSourceType } from '../utils/datasource'; +import { getInstantFromDataQuery } from '../utils/rule-form'; export function setQueryEditorSettings(values: RuleFormValues): RuleFormValues { const isQuerySwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; @@ -64,47 +65,63 @@ export function setInstantOrRange(values: RuleFormValues): RuleFormValues { }; } +/** + * A alert rule is "transformable" to a simple condition editor if + * 1. we have a single data query + * 2. we have _either_ + * 2.1 a reduce expression (pointing to the data query) _and_ a threshold expression pointing to the reducer + * 2.2 a threshold expression pointing to a (instant) data query + * ⚠️ do not assert on refIds or indexes of the queries + */ export function areQueriesTransformableToSimpleCondition( - dataQueries: Array>, + dataQueries: Array>, expressionQueries: Array> ) { + // 1. check if we only have a _single_ data query if (dataQueries.length !== 1) { return false; } - const singleReduceExpressionInInstantQuery = - 'instant' in dataQueries[0].model && dataQueries[0].model.instant && expressionQueries.length === 1; - if (expressionQueries.length !== 2 && !singleReduceExpressionInInstantQuery) { + // short-circuit when we have more than 2 expressions, we don't know what to do with that + if (expressionQueries.length > 2) { return false; } - const query = dataQueries[0]; + const dataQuery = dataQueries.at(0); - if (query.refId !== SimpleConditionIdentifier.queryId) { - return false; + // find the reduce or threshold expressions + const reduceExpression = expressionQueries.find((query) => query.model.type === ExpressionQueryType.reduce); + const thresholdExpression = expressionQueries.find((query) => query.model.type === ExpressionQueryType.threshold); + + // reducer should be set to "strict" mode + const reducerIsStrict = reduceExpression ? isStrictReducer(reduceExpression.model) : false; + // threshold expression shouldn't have an unload evaluator (custom recovery threshold) + const thresholdExpressionIsClean = + thresholdExpression?.model.conditions?.every((condition) => { + return isEmpty(condition.unloadEvaluator); + }) ?? true; + + const validReducerExpression = reduceExpression && reducerIsStrict; + const validThresholdExpression = thresholdExpression && thresholdExpressionIsClean; + + const thresholdPointingToReducer = thresholdExpression?.model.expression === reduceExpression?.refId; + const reducerPointingToDataQuery = reduceExpression?.model.expression === dataQuery?.refId; + + // 2.1 check for a reduce + threshold expression and their targets + if (validReducerExpression && reducerPointingToDataQuery && validThresholdExpression && thresholdPointingToReducer) { + return true; } - const reduceExpressionIndex = expressionQueries.findIndex( - (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId - ); - const reduceExpression = expressionQueries.at(reduceExpressionIndex); - const reduceOk = - reduceExpression && - reduceExpressionIndex === 0 && - (reduceExpression.model.settings?.mode === ReducerMode.Strict || - reduceExpression.model.settings?.mode === undefined); + // 2.2 check for a single threshold expression pointing to an "instant" data query + const isInstantDataQuery = dataQuery ? getInstantFromDataQuery(dataQuery) : false; + const hasSingleThresholdExpression = expressionQueries.length === 1 && thresholdExpression; + const thresholdPointingToDataQuery = thresholdExpression?.model.expression === dataQuery?.refId; - const thresholdExpressionIndex = expressionQueries.findIndex( - (query) => - query.model.type === ExpressionQueryType.threshold && query.refId === SimpleConditionIdentifier.thresholdId - ); - const thresholdExpression = expressionQueries.at(thresholdExpressionIndex); - const conditions = thresholdExpression?.model.conditions ?? []; - const thresholdIndexOk = singleReduceExpressionInInstantQuery - ? thresholdExpressionIndex === 0 - : thresholdExpressionIndex === 1; - const thresholdOk = thresholdExpression && thresholdIndexOk && conditions[0]?.unloadEvaluator === undefined; - return (Boolean(reduceOk) || Boolean(singleReduceExpressionInInstantQuery)) && Boolean(thresholdOk); + if (isInstantDataQuery && hasSingleThresholdExpression && validThresholdExpression && thresholdPointingToDataQuery) { + return true; + } + + return false; } export function isExpressionQueryInAlert( diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index e8939c0c336..5c13f97c954 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -1,10 +1,18 @@ import { PromQuery } from '@grafana/prometheus'; -import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto'; +import { + AlertDataQuery, + AlertQuery, + GrafanaAlertStateDecision, + GrafanaRuleDefinition, + RulerAlertingRuleDTO, +} from 'app/types/unified-alerting-dto'; +import { mockDataSource } from '../mocks'; import { getDefaultFormValues } from '../rule-editor/formDefaults'; +import { setupDataSources } from '../testSetup/datasources'; import { AlertManagerManualRouting, RuleFormType, RuleFormValues } from '../types/rule-form'; -import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { alertingRulerRuleToRuleForm, cleanAnnotations, @@ -12,6 +20,7 @@ import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, getContactPointsFromDTO, + getInstantFromDataQuery, getNotificationSettingsForDTO, } from './rule-form'; @@ -254,3 +263,71 @@ describe('cleanLabels', () => { expect(output).toStrictEqual([{ key: 'key', value: '' }]); }); }); + +describe('getInstantFromDataQuery', () => { + const query: AlertQuery = { + refId: 'Q', + datasourceUid: 'abc123', + queryType: '', + relativeTimeRange: { + from: 600, + to: 0, + }, + model: { + refId: 'Q', + }, + }; + + it('should return undefined if datasource UID is undefined', () => { + setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'Mimir-cloud', uid: 'mimir-1' })); + const result = getInstantFromDataQuery({ ...query }); + expect(result).toBeUndefined(); + }); + + it('should return undefined if datasource type is not Prometheus or Loki', () => { + setupDataSources(mockDataSource({ type: DataSourceType.Alertmanager, name: 'aa', uid: 'aa-1' })); + const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa' }); + expect(result).toBeUndefined(); + }); + + it('should return true if datasource is Prometheus and instant is not defined', () => { + setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'aa', uid: 'aa-1' })); + const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa' }); + + expect(result).toBe(true); + }); + + it('should return the value of instant if datasource is Prometheus and instant is defined', () => { + setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'aa', uid: 'aa-1' })); + const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa', model: { refId: 'f', instant: false } }); + expect(result).toBe(false); + }); + + it('should return true if datasource is Loki and queryType is not defined', () => { + setupDataSources(mockDataSource({ type: DataSourceType.Loki, name: 'aa', uid: 'aa-1' })); + const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa' }); + expect(result).toBe(true); + }); + + it('should return true if datasource is Loki and queryType is instant', () => { + setupDataSources(mockDataSource({ type: DataSourceType.Loki, name: 'aa', uid: 'aa-1' })); + const result = getInstantFromDataQuery({ + ...query, + datasourceUid: 'aa', + model: { refId: 'f', queryType: 'instant' }, + }); + + expect(result).toBe(true); + }); + + it('should return false if datasource is Loki and queryType is not instant', () => { + setupDataSources(mockDataSource({ type: DataSourceType.Loki, name: 'aa', uid: 'aa-1' })); + const result = getInstantFromDataQuery({ + ...query, + datasourceUid: 'aa', + model: { refId: 'f', queryType: 'range' }, + }); + + expect(result).toBe(false); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 194b7bec552..7845c5e228f 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -803,19 +803,22 @@ export function isPromOrLokiQuery(model: AlertDataQuery): model is PromOrLokiQue return 'expr' in model; } -export function getInstantFromDataQuery(model: AlertDataQuery, type: string): boolean | undefined { - // if the datasource is not prometheus or loki, instant is defined in the model or defaults to undefined - if (type !== DataSourceType.Prometheus && type !== DataSourceType.Loki) { - if ('instant' in model) { - return model.instant; - } else { - if ('queryType' in model) { - return model.queryType === 'instant'; - } else { - return undefined; - } - } +export function getInstantFromDataQuery(query: AlertQuery): boolean | undefined { + const dataSourceUID = query.datasourceUid ?? query.model.datasource?.uid; + if (!dataSourceUID) { + return undefined; } + + // find the datasource type from the UID + const type = getDataSourceSrv().getInstanceSettings(dataSourceUID)?.type; + + // if the datasource is not prometheus or loki, return "undefined" + if (type !== DataSourceType.Prometheus && type !== DataSourceType.Loki) { + return undefined; + } + + const { model } = query; + // if the datasource is prometheus or loki, instant is defined in the model, or defaults to true const isInstantForPrometheus = 'instant' in model && model.instant !== undefined ? model.instant : true; const isInstantForLoki = 'queryType' in model && model.queryType !== undefined ? model.queryType === 'instant' : true; diff --git a/public/app/features/expressions/utils/expressionTypes.ts b/public/app/features/expressions/utils/expressionTypes.ts index 514dfdcb907..dd648512b85 100644 --- a/public/app/features/expressions/utils/expressionTypes.ts +++ b/public/app/features/expressions/utils/expressionTypes.ts @@ -2,7 +2,7 @@ import { ReducerID } from '@grafana/data'; import { EvalFunction } from '../../alerting/state/alertDef'; import { isReducerType } from '../guards'; -import { ClassicCondition, ExpressionQuery, ExpressionQueryType, ReducerType } from '../types'; +import { ClassicCondition, ExpressionQuery, ExpressionQueryType, ReducerMode, ReducerType } from '../types'; export const getDefaults = (query: ExpressionQuery) => { switch (query.type) { @@ -69,3 +69,20 @@ export function getReducerType(value: string): ReducerType | undefined { } return undefined; } + +export function isStrictReducer(expressionModel: ExpressionQuery): boolean { + if (!isReducerExpression(expressionModel)) { + return false; + } + + const mode = expressionModel.settings?.mode; + return mode === ReducerMode.Strict || mode === undefined; +} + +export function isReducerExpression(expressionModel: ExpressionQuery) { + return expressionModel.type === ExpressionQueryType.reduce; +} + +export function isThresholdExpression(expressionModel: ExpressionQuery) { + return expressionModel.type === ExpressionQueryType.threshold; +} From 8db050af6b1ad43244ec60a815255ff019e9f0e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20=C5=A0tibran=C3=BD?= Date: Thu, 20 Mar 2025 16:50:50 +0100 Subject: [PATCH 09/79] Use in-memory sequence number generator when running integration tests against Spanner emulator. (#102522) --- .../sqlstore/migrator/spanner_dialect.go | 29 +------------- pkg/util/xorm/dialect_spanner.go | 23 +++++++++++ pkg/util/xorm/engine.go | 5 +-- pkg/util/xorm/sequence_inmem.go | 39 +++++++++++++++++++ pkg/util/xorm/xorm.go | 23 +++++++++-- 5 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 pkg/util/xorm/sequence_inmem.go diff --git a/pkg/services/sqlstore/migrator/spanner_dialect.go b/pkg/services/sqlstore/migrator/spanner_dialect.go index c66032367ff..950aabf2f2a 100644 --- a/pkg/services/sqlstore/migrator/spanner_dialect.go +++ b/pkg/services/sqlstore/migrator/spanner_dialect.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "strconv" "time" "cloud.google.com/go/spanner" @@ -15,12 +14,10 @@ import ( "github.com/googleapis/gax-go/v2" spannerdriver "github.com/googleapis/go-sql-spanner" "github.com/grafana/dskit/concurrency" - "google.golang.org/api/option" - "google.golang.org/grpc" "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" "xorm.io/core" + spannerext "github.com/grafana/grafana/pkg/extensions/spanner" "xorm.io/xorm" _ "embed" @@ -294,7 +291,7 @@ func (s *SpannerDialect) executeDDLStatements(ctx context.Context, engine *xorm. return err } - opts := SpannerConnectorConfigToClientOptions(cfg) + opts := spannerext.SpannerConnectorConfigToClientOptions(cfg) databaseAdminClient, err := database.NewDatabaseAdminClient(ctx, opts...) if err != nil { @@ -319,28 +316,6 @@ func (s *SpannerDialect) executeDDLStatements(ctx context.Context, engine *xorm. return nil } -// SpannerConnectorConfigToClientOptions is adapted from https://github.com/googleapis/go-sql-spanner/blob/main/driver.go#L341-L477, from version 1.11.1. -func SpannerConnectorConfigToClientOptions(connectorConfig spannerdriver.ConnectorConfig) []option.ClientOption { - var opts []option.ClientOption - if connectorConfig.Host != "" { - opts = append(opts, option.WithEndpoint(connectorConfig.Host)) - } - if strval, ok := connectorConfig.Params["credentials"]; ok { - opts = append(opts, option.WithCredentialsFile(strval)) - } - if strval, ok := connectorConfig.Params["credentialsjson"]; ok { - opts = append(opts, option.WithCredentialsJSON([]byte(strval))) - } - if strval, ok := connectorConfig.Params["useplaintext"]; ok { - if val, err := strconv.ParseBool(strval); err == nil && val { - opts = append(opts, - option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), - option.WithoutAuthentication()) - } - } - return opts -} - func (s *SpannerDialect) UnionDistinct() string { return "UNION DISTINCT" } diff --git a/pkg/util/xorm/dialect_spanner.go b/pkg/util/xorm/dialect_spanner.go index 4269107cbb6..df4b0e3965e 100644 --- a/pkg/util/xorm/dialect_spanner.go +++ b/pkg/util/xorm/dialect_spanner.go @@ -9,7 +9,10 @@ import ( "strings" _ "github.com/googleapis/go-sql-spanner" + spannerdriver "github.com/googleapis/go-sql-spanner" "xorm.io/core" + + spannerext "github.com/grafana/grafana/pkg/extensions/spanner" ) func init() { @@ -370,3 +373,23 @@ func (s *spanner) GetIndexes(tableName string) (map[string]*core.Index, error) { } return indexes, res.Err() } + +func (s *spanner) CreateSequenceGenerator(db *sql.DB) (SequenceGenerator, error) { + dsn := s.DataSourceName() + connectorConfig, err := spannerdriver.ExtractConnectorConfig(dsn) + if err != nil { + return nil, err + } + + if spannerext.UsePlainText(connectorConfig) { + // Plain-text means we're either using spannertest or Spanner emulator. + // Switch to fake in-memory sequence number generator in that case. + // + // Using database-based sequence generator doesn't work with emulator, as emulator + // only supports single transaction. If there is already another transaction started + // generating new ID via database-based sequence generator would always fail. + return newInMemSequenceGenerator(), nil + } + + return newSequenceGenerator(db), nil +} diff --git a/pkg/util/xorm/engine.go b/pkg/util/xorm/engine.go index 8db50041ae2..4d29facbd77 100644 --- a/pkg/util/xorm/engine.go +++ b/pkg/util/xorm/engine.go @@ -44,7 +44,7 @@ type Engine struct { tagHandlers map[string]tagHandler defaultContext context.Context - sequenceGenerator *sequenceGenerator // If not nil, this generator is used to generate auto-increment values for inserts. + sequenceGenerator SequenceGenerator // If not nil, this generator is used to generate auto-increment values for inserts. } // CondDeleted returns the conditions whether a record is soft deleted. @@ -239,9 +239,6 @@ func (engine *Engine) NewSession() *Session { // Close the engine func (engine *Engine) Close() error { - if engine.sequenceGenerator != nil { - engine.sequenceGenerator.close() - } return engine.db.Close() } diff --git a/pkg/util/xorm/sequence_inmem.go b/pkg/util/xorm/sequence_inmem.go new file mode 100644 index 00000000000..e412e687664 --- /dev/null +++ b/pkg/util/xorm/sequence_inmem.go @@ -0,0 +1,39 @@ +package xorm + +import ( + "context" + "fmt" + "math/rand/v2" + "sync" +) + +type inMemSequenceGenerator struct { + sequencesMu sync.Mutex + nextValues map[string]int +} + +func newInMemSequenceGenerator() *inMemSequenceGenerator { + return &inMemSequenceGenerator{ + nextValues: make(map[string]int), + } +} + +func (g *inMemSequenceGenerator) Next(_ context.Context, table, column string) (int64, error) { + if table == "migration_log" { + // Don't use sequential IDs for migration log entries, as we don't clean up migration_log table between tests, + // so restarting the sequence can lead to conflicting IDs. + return rand.Int64(), nil + } + + key := fmt.Sprintf("%s:%s", table, column) + + g.sequencesMu.Lock() + defer g.sequencesMu.Unlock() + + seq, ok := g.nextValues[key] + if !ok { + seq = 1 + } + g.nextValues[key] = seq + 1 + return int64(seq), nil +} diff --git a/pkg/util/xorm/xorm.go b/pkg/util/xorm/xorm.go index 25ce8bd952f..109a0bca16a 100644 --- a/pkg/util/xorm/xorm.go +++ b/pkg/util/xorm/xorm.go @@ -9,6 +9,7 @@ package xorm import ( "context" + "database/sql" "fmt" "os" "reflect" @@ -22,6 +23,8 @@ import ( const ( // Version show the xorm's version Version string = "0.8.0.1015" + + Spanner = "spanner" ) func regDrvsNDialects() bool { @@ -97,7 +100,7 @@ func NewEngine(driverName string, dataSourceName string) (*Engine, error) { switch uri.DbType { case core.SQLITE: engine.DatabaseTZ = time.UTC - case "spanner": + case Spanner: engine.DatabaseTZ = time.UTC // We need to specify "Z" to indicate that timestamp is in UTC. // Otherwise Spanner uses default America/Los_Angeles timezone. @@ -114,9 +117,23 @@ func NewEngine(driverName string, dataSourceName string) (*Engine, error) { runtime.SetFinalizer(engine, close) - if dialect.DBType() == "spanner" { - engine.sequenceGenerator = newSequenceGenerator(db.DB) + if ext, ok := dialect.(DialectExt); ok { + engine.sequenceGenerator, err = ext.CreateSequenceGenerator(db.DB) + if err != nil { + return nil, fmt.Errorf("failed to create sequence generator: %w", err) + } } return engine, nil } + +type SequenceGenerator interface { + Next(ctx context.Context, table, column string) (int64, error) +} + +type DialectExt interface { + core.Dialect + + // CreateSequenceGenerator returns optional generator used to create AUTOINCREMENT ids for inserts. + CreateSequenceGenerator(db *sql.DB) (SequenceGenerator, error) +} From 8cdbc51b040737c9a1f0aef4014934e7b8c9a31d Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Thu, 20 Mar 2025 16:26:09 +0000 Subject: [PATCH 10/79] feat: expose Live message size limit to frontend (#100169) Developers using Grafana Live need to know whether a message is too big to be sent over the Grafana Live websocket. Since this limit is configurable, it is useful to expose it to the frontend. This commit adds a new field to the frontend settings, `liveMessageSizeLimit`, which the frontend can use to access the limit configured in the backend. Relates to #99770. --- packages/grafana-data/src/types/config.ts | 1 + packages/grafana-runtime/src/config.ts | 1 + pkg/api/dtos/frontend_settings.go | 29 ++++++++++++----------- pkg/api/frontendsettings.go | 1 + 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index c982e0fe25c..a85914216ec 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -199,6 +199,7 @@ export interface GrafanaConfig { trustedTypesDefaultPolicyEnabled: boolean; cspReportOnlyEnabled: boolean; liveEnabled: boolean; + liveMessageSizeLimit: number; /** @deprecated Use `theme2` instead. */ theme: GrafanaTheme; theme2: GrafanaTheme2; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 5d706a2b26e..8990d064bd7 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -107,6 +107,7 @@ export class GrafanaBootConfig implements GrafanaConfig { trustedTypesDefaultPolicyEnabled = false; cspReportOnlyEnabled = false; liveEnabled = true; + liveMessageSizeLimit = 65536; /** @deprecated Use `theme2` instead. */ theme: GrafanaTheme; theme2: GrafanaTheme2; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index c88a8f4cf64..19306e3d4aa 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -154,20 +154,21 @@ type FrontendSettingsSqlConnectionLimitsDTO struct { } type FrontendSettingsDTO struct { - DefaultDatasource string `json:"defaultDatasource"` - Datasources map[string]plugins.DataSourceDTO `json:"datasources"` - MinRefreshInterval string `json:"minRefreshInterval"` - Panels map[string]plugins.PanelDTO `json:"panels"` - Apps map[string]*plugins.AppDTO `json:"apps"` - AppUrl string `json:"appUrl"` - AppSubUrl string `json:"appSubUrl"` - AllowOrgCreate bool `json:"allowOrgCreate"` - AuthProxyEnabled bool `json:"authProxyEnabled"` - LdapEnabled bool `json:"ldapEnabled"` - JwtHeaderName string `json:"jwtHeaderName"` - JwtUrlLogin bool `json:"jwtUrlLogin"` - LiveEnabled bool `json:"liveEnabled"` - AutoAssignOrg bool `json:"autoAssignOrg"` + DefaultDatasource string `json:"defaultDatasource"` + Datasources map[string]plugins.DataSourceDTO `json:"datasources"` + MinRefreshInterval string `json:"minRefreshInterval"` + Panels map[string]plugins.PanelDTO `json:"panels"` + Apps map[string]*plugins.AppDTO `json:"apps"` + AppUrl string `json:"appUrl"` + AppSubUrl string `json:"appSubUrl"` + AllowOrgCreate bool `json:"allowOrgCreate"` + AuthProxyEnabled bool `json:"authProxyEnabled"` + LdapEnabled bool `json:"ldapEnabled"` + JwtHeaderName string `json:"jwtHeaderName"` + JwtUrlLogin bool `json:"jwtUrlLogin"` + LiveEnabled bool `json:"liveEnabled"` + LiveMessageSizeLimit int `json:"liveMessageSizeLimit"` + AutoAssignOrg bool `json:"autoAssignOrg"` VerifyEmailEnabled bool `json:"verifyEmailEnabled"` SigV4AuthEnabled bool `json:"sigV4AuthEnabled"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 9c816097407..070e977af90 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -194,6 +194,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro JwtHeaderName: hs.Cfg.JWTAuth.HeaderName, JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin, LiveEnabled: hs.Cfg.LiveMaxConnections != 0, + LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit, AutoAssignOrg: hs.Cfg.AutoAssignOrg, VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled, SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled, From 24ebacb10bcf73828847d2e89069c260767e6bb5 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Thu, 20 Mar 2025 12:34:36 -0400 Subject: [PATCH 11/79] Alerting: Add migration to clean up rule versions table (#102484) * add migration to clean up rule versions * drop index right before creating a new one. * fetch only rules which version greater than toKeep --- .../ualert/alert_rule_version_guid_mig.go | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go b/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go index 3713e4eaa0a..ae1c1945e36 100644 --- a/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go +++ b/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go @@ -2,6 +2,8 @@ package ualert import ( "fmt" + "os" + "strconv" "strings" "github.com/google/uuid" @@ -29,10 +31,13 @@ func AddAlertRuleGuidMigration(mg *migrator.Migrator) { Nullable: false, Default: "''", })) - mg.AddMigration("drop index in alert_rule_version table on rule_org_id, rule_uid and version columns", migrator.NewDropIndexMigration(alertRuleVersion, alertRuleVersionUDX_OrgIdRuleUIDVersion)) + + mg.AddMigration("cleanup alert_rule_version table", &cleanUpRuleVersionsMigration{}) mg.AddMigration("populate rule guid in alert rule table", &setRuleGuidMigration{}) + mg.AddMigration("drop index in alert_rule_version table on rule_org_id, rule_uid and version columns", migrator.NewDropIndexMigration(alertRuleVersion, alertRuleVersionUDX_OrgIdRuleUIDVersion)) + mg.AddMigration("add index in alert_rule_version table on rule_org_id, rule_uid, rule_guid and version columns", migrator.NewAddIndexMigration(alertRuleVersion, &migrator.Index{Cols: []string{"rule_org_id", "rule_uid", "rule_guid", "version"}, Type: migrator.UniqueIndex}, @@ -118,3 +123,80 @@ func (c setRuleGuidMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) er } return nil } + +type cleanUpRuleVersionsMigration struct { + migrator.MigrationBase +} + +var _ migrator.CodeMigration = (*cleanUpRuleVersionsMigration)(nil) + +func (c cleanUpRuleVersionsMigration) SQL(migrator.Dialect) string { + return codeMigration +} + +func getBatchSize() int { + const defaultBatchSize = 50 + envvar := os.Getenv("ALERT_RULE_VERSION_CLEANUP_MIGRATION_BATCH_SIZE") + if envvar == "" { + return defaultBatchSize + } + batchSize, err := strconv.Atoi(envvar) + if err != nil { + return defaultBatchSize + } + return batchSize +} + +func (c cleanUpRuleVersionsMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + var batchSize = getBatchSize() + + const maxRetention = 100 + toKeep := mg.Cfg.UnifiedAlerting.RuleVersionRecordLimit + if toKeep <= 0 { + mg.Logger.Info("Rule version record limit is not set, fallback to 100", "limit", toKeep) + toKeep = maxRetention + } + + var rules []alertRule + err := sess.Table(alertRule{}).Select("uid, version").Where("version > ?", toKeep).Find(&rules) + if err != nil { + return err + } + mg.Logger.Debug("Got alert rule UIDs with versions greater than retention", "count", len(rules)) + batches := len(rules) / batchSize + if len(rules)%batchSize != 0 { + batches++ + } + + mg.Logger.Info("Cleaning up table `alert_rule_version`", "batchSize", batchSize, "batches", batches, "keepVersions", toKeep) + + for i := 0; i < batches; i++ { + end := i*batchSize + batchSize + if end > len(rules) { + end = len(rules) + } + bd := strings.Builder{} + for idx, r := range rules[i*batchSize : end] { + if idx == 0 { + bd.WriteString(fmt.Sprintf("SELECT '%s' as uid, %d as version", r.UID, r.Version)) + continue + } + bd.WriteString(fmt.Sprintf(" UNION ALL SELECT '%s', %d ", r.UID, r.Version)) + } + _, err = sess.Exec(fmt.Sprintf(` + DELETE FROM alert_rule_version + WHERE EXISTS ( + SELECT 1 + FROM (%s) AR + WHERE AR.uid = alert_rule_version.rule_uid + AND alert_rule_version.version < AR.version - %d + )`, bd.String(), toKeep), + ) + if err != nil { + return err + } + + mg.Logger.Debug(fmt.Sprintf("Batch %d of %d processed", i+1, batches)) + } + return nil +} From 9fce4311e9bbce15540ba43f43e93cef5981521e Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Thu, 20 Mar 2025 16:50:52 +0000 Subject: [PATCH 12/79] Live: allow publishing over Centrifuge subscription (#102325) * Live: allow publishing over Centrifuge subscription Currently when publishing over a Grafana Live channel, the data is sent over the HTTP API. This works fine when there is only a single Grafana instance running, but when there are multiple instances, the data will only hit one instance, which is often not desired: sometimes you need to guarantee that the data appears on the same instance that the frontend is connected to. An example of this is in the Grafana LLM app when running the MCP server. The MCP protocol is stateful; users subscribe to a channel to get a long-lived stream of server-sent events, then send subsequent requests to the server to get further results. If there are multiple Grafana instances running then the requests are likely to land on an instance other than the one that the user is connected to. This commit adds a new option to the `GrafanaLiveSrv` interface that allows the user to publish data over the Centrifuge subscription instead of the HTTP API. This is not the default and should rarely be used, but is required to fulfil certain use cases. * Address nits from code review Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> --------- Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> --- packages/grafana-runtime/src/services/live.ts | 16 +++++++++++++++- public/app/features/live/centrifuge/channel.ts | 2 ++ public/app/features/live/centrifuge/service.ts | 10 +++++++++- .../features/live/centrifuge/service.worker.ts | 6 +++++- .../live/centrifuge/serviceWorkerProxy.ts | 4 ++++ public/app/features/live/live.ts | 6 +++++- 6 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/grafana-runtime/src/services/live.ts b/packages/grafana-runtime/src/services/live.ts index e3787383995..41c05ac76bd 100644 --- a/packages/grafana-runtime/src/services/live.ts +++ b/packages/grafana-runtime/src/services/live.ts @@ -39,6 +39,20 @@ export interface LiveQueryDataOptions { body: unknown; // processed queries, same as sent to `/api/query/ds` } +/** + * @alpha -- experimental + */ +export interface LivePublishOptions { + /** + * Publish the data over the websocket instead of the HTTP API. + * + * This is not recommended for most use cases. + * + * @experimental + */ + useSocket?: boolean; +} + /** * @alpha -- experimental */ @@ -79,7 +93,7 @@ export interface GrafanaLiveSrv { * * @alpha -- experimental */ - publish(address: LiveChannelAddress, data: unknown): Promise; + publish(address: LiveChannelAddress, data: unknown, options?: LivePublishOptions): Promise; } let singletonInstance: GrafanaLiveSrv; diff --git a/public/app/features/live/centrifuge/channel.ts b/public/app/features/live/centrifuge/channel.ts index fd2f6a109f1..2edb1fc470b 100644 --- a/public/app/features/live/centrifuge/channel.ts +++ b/public/app/features/live/centrifuge/channel.ts @@ -175,6 +175,8 @@ export class CentrifugeLiveChannel { }); } + publish = (data: unknown) => this.subscription?.publish(data); + /** * This will close and terminate all streams for this channel */ diff --git a/public/app/features/live/centrifuge/service.ts b/public/app/features/live/centrifuge/service.ts index 8fb666a604c..b99a78d2f10 100644 --- a/public/app/features/live/centrifuge/service.ts +++ b/public/app/features/live/centrifuge/service.ts @@ -20,6 +20,7 @@ import { FetchResponse } from '@grafana/runtime/src/services/backendSrv'; import { GrafanaLiveSrv, LiveDataStreamOptions, + LivePublishOptions, LiveQueryDataOptions, StreamingFrameAction, StreamingFrameOptions, @@ -42,7 +43,7 @@ export type CentrifugeSrvDeps = { export type StreamingDataQueryResponse = Omit & { data: [StreamingResponseData] }; -export type CentrifugeSrv = Omit & { +export type CentrifugeSrv = Omit & { getDataStream: (options: LiveDataStreamOptions) => Observable; getQueryData: ( options: LiveQueryDataOptions @@ -244,6 +245,13 @@ export class CentrifugeService implements CentrifugeSrv { getPresence: CentrifugeSrv['getPresence'] = (address) => { return this.getChannel(address).getPresence(); }; + + /** + * Publish into a channel. + */ + publish = async (address: LiveChannelAddress, data: unknown, options?: LivePublishOptions) => { + return this.getChannel(address).publish(data); + }; } // This is used to give a unique key for each stream. The actual value does not matter diff --git a/public/app/features/live/centrifuge/service.worker.ts b/public/app/features/live/centrifuge/service.worker.ts index a168bc34824..0c535bc9cb8 100644 --- a/public/app/features/live/centrifuge/service.worker.ts +++ b/public/app/features/live/centrifuge/service.worker.ts @@ -3,7 +3,7 @@ import './transferHandlers'; import * as comlink from 'comlink'; import { LiveChannelAddress } from '@grafana/data'; -import { LiveDataStreamOptions, LiveQueryDataOptions } from '@grafana/runtime'; +import { LiveDataStreamOptions, LivePublishOptions, LiveQueryDataOptions } from '@grafana/runtime'; import { remoteObservableAsObservable } from './remoteObservable'; import { CentrifugeService, CentrifugeSrvDeps } from './service'; @@ -42,6 +42,9 @@ const getPresence = async (address: LiveChannelAddress) => { return await centrifuge.getPresence(address); }; +const publish = (address: LiveChannelAddress, data: unknown, options?: LivePublishOptions) => + centrifuge.publish(address, data, options); + const workObj = { initialize, getConnectionState, @@ -49,6 +52,7 @@ const workObj = { getStream, getQueryData, getPresence, + publish, }; export type RemoteCentrifugeService = typeof workObj; diff --git a/public/app/features/live/centrifuge/serviceWorkerProxy.ts b/public/app/features/live/centrifuge/serviceWorkerProxy.ts index efb87e3cdd5..6f1b8fcc92d 100644 --- a/public/app/features/live/centrifuge/serviceWorkerProxy.ts +++ b/public/app/features/live/centrifuge/serviceWorkerProxy.ts @@ -47,4 +47,8 @@ export class CentrifugeServiceWorkerProxy implements CentrifugeSrv { this.centrifugeWorker.getStream(address) as Promise>>> ); }; + + publish: CentrifugeSrv['publish'] = (address, data, options) => { + return this.centrifugeWorker.publish(address, data, options); + }; } diff --git a/public/app/features/live/live.ts b/public/app/features/live/live.ts index e4ecc3699ea..43f665f5276 100644 --- a/public/app/features/live/live.ts +++ b/public/app/features/live/live.ts @@ -93,7 +93,11 @@ export class GrafanaLiveService implements GrafanaLiveSrv { * * @alpha -- experimental */ - publish: GrafanaLiveSrv['publish'] = async (address, data) => { + publish: GrafanaLiveSrv['publish'] = async (address, data, options) => { + if (options?.useSocket) { + return this.deps.centrifugeSrv.publish(address, data); + } + return this.deps.backendSrv.post(`api/live/publish`, { channel: toLiveChannelId(address), // orgId is from user data, From 0845c781ae53f612439e7042b01d80009e388ec5 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 20 Mar 2025 19:57:05 +0300 Subject: [PATCH 13/79] K8s/Storage: Refactor Upsert (create from update) (#102528) --- pkg/apimachinery/utils/meta.go | 6 +- pkg/storage/unified/apistore/store.go | 160 +++++++++++--------------- pkg/storage/unified/apistore/util.go | 16 --- 3 files changed, 74 insertions(+), 108 deletions(-) diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index c285c202bc7..5ae3014c8c3 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -143,9 +143,13 @@ type grafanaMetaAccessor struct { // required fields are missing. Fields that are not required return the default // value and are a no-op if set. func MetaAccessor(raw interface{}) (GrafanaMetaAccessor, error) { + if raw == nil { + return nil, fmt.Errorf("unable to read metadata from nil object") + } + obj, err := meta.Accessor(raw) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to read metadata from: %T, %s", raw, err) } // reflection to find title and other non object properties diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index 69fc83f803c..966ba119c0a 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -445,11 +445,11 @@ func (s *Storage) GuaranteedUpdate( cachedExistingObject runtime.Object, ) error { var ( - res storage.ResponseMeta - updatedObj runtime.Object - existingObj runtime.Object - created bool - err error + res storage.ResponseMeta + updatedObj runtime.Object + existingObj runtime.Object + existingBytes []byte + err error ) req := &resource.UpdateRequest{} req.Key, err = s.getKey(key) @@ -465,61 +465,71 @@ func (s *Storage) GuaranteedUpdate( for attempt := 1; attempt <= MaxUpdateAttempts; attempt = attempt + 1 { // Read the latest value - rsp, err := s.store.Read(ctx, &resource.ReadRequest{Key: req.Key}) + readResponse, err := s.store.Read(ctx, &resource.ReadRequest{Key: req.Key}) if err != nil { return resource.GetError(resource.AsErrorResult(err)) } - if rsp.Error != nil { - if rsp.Error.Code == http.StatusNotFound { + if readResponse.Error != nil { + if readResponse.Error.Code == http.StatusNotFound { if !ignoreNotFound { return apierrors.NewNotFound(s.gr, req.Key.Name) } } else { - return resource.GetError(rsp.Error) + return resource.GetError(readResponse.Error) } } - created = true - existingObj = s.newFunc() - if len(rsp.Value) > 0 { - created = false - _, err = s.convertToObject(rsp.Value, existingObj) + // Upsert? (create because it does not already exist) + if len(readResponse.Value) == 0 { + if !ignoreNotFound { + return apierrors.NewNotFound(s.gr, req.Key.Name) + } + + updatedObj, _, err = tryUpdate(s.newFunc(), res) if err != nil { - return err - } - - mmm, err := utils.MetaAccessor(existingObj) - if err != nil { - return err - } - mmm.SetResourceVersionInt64(rsp.ResourceVersion) - res.ResourceVersion = uint64(rsp.ResourceVersion) - - if rest.IsDualWriteUpdate(ctx) { - // Ignore the RV when updating legacy values - mmm.SetResourceVersion("") - } else { - if err := preconditions.Check(key, existingObj); err != nil { - if attempt >= MaxUpdateAttempts { - return fmt.Errorf("precondition failed: %w", err) - } - continue - } - } - - // restore the full original object before tryUpdate - if s.opts.LargeObjectSupport != nil && mmm.GetBlob() != nil { - err = s.opts.LargeObjectSupport.Reconstruct(ctx, req.Key, s.store, mmm) - if err != nil { + if attempt >= MaxUpdateAttempts { return err } + continue } - } else if !ignoreNotFound { - return apierrors.NewNotFound(s.gr, req.Key.Name) + return s.Create(ctx, key, updatedObj, destination, 0) } - updatedObj, _, err = tryUpdate(existingObj.DeepCopyObject(), res) + existingBytes = readResponse.Value + existingObj, err = s.convertToObject(readResponse.Value, s.newFunc()) + if err != nil { + return err + } + + existing, err := utils.MetaAccessor(existingObj) + if err != nil { + return err + } + existing.SetResourceVersionInt64(readResponse.ResourceVersion) + res.ResourceVersion = uint64(readResponse.ResourceVersion) + + if rest.IsDualWriteUpdate(ctx) { + // Ignore the RV when updating legacy values + existing.SetResourceVersion("") + } else { + if err := preconditions.Check(key, existingObj); err != nil { + if attempt >= MaxUpdateAttempts { + return fmt.Errorf("precondition failed: %w", err) + } + continue + } + } + + // restore the full original object before tryUpdate + if s.opts.LargeObjectSupport != nil && existing.GetBlob() != nil { + err = s.opts.LargeObjectSupport.Reconstruct(ctx, req.Key, s.store, existing) + if err != nil { + return err + } + } + + updatedObj, _, err = tryUpdate(existingObj, res) if err != nil { if attempt >= MaxUpdateAttempts { return err @@ -529,64 +539,32 @@ func (s *Storage) GuaranteedUpdate( break } - unchanged, err := isUnchanged(s.codec, existingObj, updatedObj) + req.Value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj) if err != nil { return err } - if unchanged { - var buf bytes.Buffer - if err = s.codec.Encode(updatedObj, &buf); err != nil { - return err - } - if _, err := s.convertToObject(buf.Bytes(), destination); err != nil { - return err - } - return nil - } - - var ( - value []byte - rv int64 - ) - if created { - value, err = s.prepareObjectForStorage(ctx, updatedObj) - if err != nil { - return err - } - rsp2, err := s.store.Create(ctx, &resource.CreateRequest{ - Key: req.Key, - Value: value, - }) + var rv uint64 + // Only update (for real) if the bytes have changed + if !bytes.Equal(req.Value, existingBytes) { + updateResponse, err := s.store.Update(ctx, req) if err != nil { return resource.GetError(resource.AsErrorResult(err)) } - if rsp2.Error != nil { - return resource.GetError(rsp2.Error) + if updateResponse.Error != nil { + return resource.GetError(updateResponse.Error) } - rv = rsp2.ResourceVersion - } else { - value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj) - if err != nil { + rv = uint64(updateResponse.ResourceVersion) + } + + if _, err := s.convertToObject(req.Value, destination); err != nil { + return err + } + + if rv > 0 { + if err := s.versioner.UpdateObject(destination, rv); err != nil { return err } - req.Value = value - rsp2, err := s.store.Update(ctx, req) - if err != nil { - return resource.GetError(resource.AsErrorResult(err)) - } - if rsp2.Error != nil { - return resource.GetError(rsp2.Error) - } - rv = rsp2.ResourceVersion - } - - if _, err := s.convertToObject(value, destination); err != nil { - return err - } - - if err := s.versioner.UpdateObject(destination, uint64(rv)); err != nil { - return err } return nil diff --git a/pkg/storage/unified/apistore/util.go b/pkg/storage/unified/apistore/util.go index a716bd77024..f0979972688 100644 --- a/pkg/storage/unified/apistore/util.go +++ b/pkg/storage/unified/apistore/util.go @@ -6,13 +6,11 @@ package apistore import ( - "bytes" "fmt" "strconv" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/selection" "k8s.io/apiserver/pkg/storage" @@ -112,17 +110,3 @@ func toListRequest(k *resource.ResourceKey, opts storage.ListOptions) (*resource return req, predicate, nil } - -func isUnchanged(codec runtime.Codec, obj runtime.Object, newObj runtime.Object) (bool, error) { - buf := new(bytes.Buffer) - if err := codec.Encode(obj, buf); err != nil { - return false, err - } - - newBuf := new(bytes.Buffer) - if err := codec.Encode(newObj, newBuf); err != nil { - return false, err - } - - return bytes.Equal(buf.Bytes(), newBuf.Bytes()), nil -} From ad71270ee09cce1c9b15d80445756c959b5d2119 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Thu, 20 Mar 2025 18:05:55 +0100 Subject: [PATCH 14/79] Alerting: Support query_offset in the Prometheus conversion (#102499) Adds support for rule group-level query_offset in Prometheus to Grafana rule conversion. It allows specifying a time offset for rule evaluation, which gets applied and saved during the conversion. --- pkg/services/ngalert/api/tooling/api.json | 25 ++++++++++++++++++- .../definitions/convert_prometheus_api.go | 9 ++++--- pkg/services/ngalert/api/tooling/post.json | 24 ++++++++++++++++++ pkg/services/ngalert/api/tooling/spec.json | 24 ++++++++++++++++++ pkg/services/ngalert/prom/convert.go | 14 ++++++++--- pkg/services/ngalert/prom/convert_test.go | 25 +++++++++++-------- pkg/services/ngalert/prom/models.go | 8 +++--- pkg/services/ngalert/prom/models_test.go | 7 +++--- pkg/services/ngalert/prom/query.go | 2 +- public/api-merged.json | 25 ++++++++++++++++++- public/openapi3.json | 25 ++++++++++++++++++- 11 files changed, 160 insertions(+), 28 deletions(-) diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 49ab21484b0..12bee7c6447 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -208,6 +208,9 @@ "isPaused": { "type": "boolean" }, + "keepFiringFor": { + "$ref": "#/definitions/Duration" + }, "labels": { "additionalProperties": { "type": "string" @@ -468,6 +471,10 @@ "health": { "type": "string" }, + "keepFiringFor": { + "format": "double", + "type": "number" + }, "labels": { "$ref": "#/definitions/Labels" }, @@ -3012,9 +3019,22 @@ "Interval": { "$ref": "#/definitions/Duration" }, + "Labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "Limit": { + "format": "int64", + "type": "integer" + }, "Name": { "type": "string" }, + "QueryOffset": { + "type": "string" + }, "Rules": { "items": { "$ref": "#/definitions/PrometheusRule" @@ -3117,6 +3137,10 @@ "example": false, "type": "boolean" }, + "keep_firing_for": { + "format": "duration", + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" @@ -4952,7 +4976,6 @@ "type": "object" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert", "type": "object" diff --git a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go index f0202486e46..6e9f0f2bd4c 100644 --- a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go +++ b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go @@ -192,9 +192,12 @@ type PrometheusNamespace struct { // swagger:model type PrometheusRuleGroup struct { - Name string `yaml:"name"` - Interval model.Duration `yaml:"interval"` - Rules []PrometheusRule `yaml:"rules"` + Name string `yaml:"name"` + Interval model.Duration `yaml:"interval"` + QueryOffset *model.Duration `yaml:"query_offset,omitempty"` + Limit int `yaml:"limit,omitempty"` + Rules []PrometheusRule `yaml:"rules"` + Labels map[string]string `yaml:"labels,omitempty"` } // swagger:model diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 3c9cc03dbec..5968f49fdbc 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -208,6 +208,9 @@ "isPaused": { "type": "boolean" }, + "keepFiringFor": { + "$ref": "#/definitions/Duration" + }, "labels": { "additionalProperties": { "type": "string" @@ -468,6 +471,10 @@ "health": { "type": "string" }, + "keepFiringFor": { + "format": "double", + "type": "number" + }, "labels": { "$ref": "#/definitions/Labels" }, @@ -3012,9 +3019,22 @@ "Interval": { "$ref": "#/definitions/Duration" }, + "Labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "Limit": { + "format": "int64", + "type": "integer" + }, "Name": { "type": "string" }, + "QueryOffset": { + "type": "string" + }, "Rules": { "items": { "$ref": "#/definitions/PrometheusRule" @@ -3117,6 +3137,10 @@ "example": false, "type": "boolean" }, + "keep_firing_for": { + "format": "duration", + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 15d8a288303..706a40d67fd 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -4433,6 +4433,9 @@ "isPaused": { "type": "boolean" }, + "keepFiringFor": { + "$ref": "#/definitions/Duration" + }, "labels": { "type": "object", "additionalProperties": { @@ -4703,6 +4706,10 @@ "health": { "type": "string" }, + "keepFiringFor": { + "type": "number", + "format": "double" + }, "labels": { "$ref": "#/definitions/Labels" }, @@ -7238,9 +7245,22 @@ "Interval": { "$ref": "#/definitions/Duration" }, + "Labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Limit": { + "type": "integer", + "format": "int64" + }, "Name": { "type": "string" }, + "QueryOffset": { + "type": "string" + }, "Rules": { "type": "array", "items": { @@ -7354,6 +7374,10 @@ "type": "boolean", "example": false }, + "keep_firing_for": { + "type": "string", + "format": "duration" + }, "labels": { "type": "object", "additionalProperties": { diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index c86126bfb90..12e97106461 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -199,7 +199,7 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom var err error isRecordingRule := rule.Record != "" - query, err = p.createQuery(rule.Expr, isRecordingRule) + query, err = p.createQuery(rule.Expr, isRecordingRule, promGroup) if err != nil { return models.AlertRule{}, err } @@ -265,8 +265,16 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom // // This is needed to ensure that we keep the Prometheus behaviour, where any returned result // is considered alerting, and only when the query returns no data is the alert treated as normal. -func (p *Converter) createQuery(expr string, isRecordingRule bool) ([]models.AlertQuery, error) { - queryNode, err := createQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, expr, *p.cfg.FromTimeRange, *p.cfg.EvaluationOffset) +func (p *Converter) createQuery(expr string, isRecordingRule bool, promGroup PrometheusRuleGroup) ([]models.AlertQuery, error) { + // If evaluation offset is set on the group level, use that, otherwise use the global evaluation offset. + var evaluationOffset time.Duration + if promGroup.QueryOffset != nil { + evaluationOffset = time.Duration(*promGroup.QueryOffset) + } else { + evaluationOffset = *p.cfg.EvaluationOffset + } + + queryNode, err := createQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, expr, *p.cfg.FromTimeRange, evaluationOffset) if err != nil { return nil, err } diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 7e0149ac14a..4e92a090b19 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -36,8 +36,9 @@ func TestPrometheusRulesToGrafana(t *testing.T) { orgID: 1, namespace: "some-namespace-uid", promGroup: PrometheusRuleGroup{ - Name: "test-group-1", - Interval: prommodel.Duration(10 * time.Second), + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), + QueryOffset: util.Pointer(prommodel.Duration(1 * time.Minute)), Rules: []PrometheusRule{ { Alert: "alert-1", @@ -124,16 +125,13 @@ func TestPrometheusRulesToGrafana(t *testing.T) { expectError: false, }, { - name: "rule group with query_offset is not supported", + name: "query_offset must be >= 0", orgID: 1, namespace: "namespaceUID", promGroup: PrometheusRuleGroup{ - Name: "test-group-1", - Interval: prommodel.Duration(10 * time.Second), - QueryOffset: func() *prommodel.Duration { - d := prommodel.Duration(30 * time.Second) - return &d - }(), + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), + QueryOffset: util.Pointer(prommodel.Duration(-1)), Rules: []PrometheusRule{ { Alert: "alert-1", @@ -142,7 +140,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) { }, }, expectError: true, - errorMsg: "query_offset is not supported", + errorMsg: "query_offset must be >= 0", }, { name: "rule group with limit is not supported", @@ -275,8 +273,13 @@ func TestPrometheusRulesToGrafana(t *testing.T) { if tc.config.EvaluationOffset != nil { evalOffset = *tc.config.EvaluationOffset } + if tc.promGroup.QueryOffset != nil { + // group-level offset takes precedence + evalOffset = time.Duration(*tc.promGroup.QueryOffset) + } + require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To) - require.Equal(t, models.Duration(evalOffset+10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From) + require.Equal(t, models.Duration(10*time.Minute+evalOffset), grafanaRule.Data[0].RelativeTimeRange.From) originalRuleDefinition, err := yaml.Marshal(promRule) require.NoError(t, err) diff --git a/pkg/services/ngalert/prom/models.go b/pkg/services/ngalert/prom/models.go index 21ea45061dc..0ef0a301885 100644 --- a/pkg/services/ngalert/prom/models.go +++ b/pkg/services/ngalert/prom/models.go @@ -25,14 +25,14 @@ type PrometheusRuleGroup struct { } func (g *PrometheusRuleGroup) Validate() error { - if g.QueryOffset != nil { - return ErrPrometheusRuleGroupValidationFailed.Errorf("query_offset is not supported") - } - if g.Limit != 0 { return ErrPrometheusRuleGroupValidationFailed.Errorf("limit is not supported") } + if g.QueryOffset != nil && *g.QueryOffset < prommodel.Duration(0) { + return ErrPrometheusRuleGroupValidationFailed.Errorf("query_offset must be >= 0") + } + for _, rule := range g.Rules { if err := rule.Validate(); err != nil { return err diff --git a/pkg/services/ngalert/prom/models_test.go b/pkg/services/ngalert/prom/models_test.go index b2bdb734b44..90be51c140c 100644 --- a/pkg/services/ngalert/prom/models_test.go +++ b/pkg/services/ngalert/prom/models_test.go @@ -26,6 +26,7 @@ func TestPrometheusRuleGroup_Validate(t *testing.T) { Labels: map[string]string{ "label-1": "value-1", }, + QueryOffset: util.Pointer(prommodel.Duration(time.Duration(1) * time.Second)), Rules: []PrometheusRule{ { Alert: "test_alert", @@ -36,14 +37,14 @@ func TestPrometheusRuleGroup_Validate(t *testing.T) { expectError: false, }, { - name: "invalid group with query_offset", + name: "invalid group with negative query_offset", group: PrometheusRuleGroup{ Name: "test_group", Interval: prommodel.Duration(60), - QueryOffset: util.Pointer(prommodel.Duration(10)), + QueryOffset: util.Pointer(prommodel.Duration(-1)), }, expectError: true, - errorMsg: "query_offset is not supported", + errorMsg: "query_offset must be >= 0", }, { name: "invalid group with limit", diff --git a/pkg/services/ngalert/prom/query.go b/pkg/services/ngalert/prom/query.go index 74aed34f83f..8e28b0ac3e7 100644 --- a/pkg/services/ngalert/prom/query.go +++ b/pkg/services/ngalert/prom/query.go @@ -43,7 +43,7 @@ func createQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange, RefID: queryRefID, RelativeTimeRange: models.RelativeTimeRange{ From: models.Duration(fromTimeRange + evaluationOffset), - To: models.Duration(0 + evaluationOffset), + To: models.Duration(evaluationOffset), }, }, nil } diff --git a/public/api-merged.json b/public/api-merged.json index 6beecc94824..3ca4e15b213 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -12654,6 +12654,9 @@ "isPaused": { "type": "boolean" }, + "keepFiringFor": { + "$ref": "#/definitions/Duration" + }, "labels": { "type": "object", "additionalProperties": { @@ -12924,6 +12927,10 @@ "health": { "type": "string" }, + "keepFiringFor": { + "type": "number", + "format": "double" + }, "labels": { "$ref": "#/definitions/Labels" }, @@ -18688,9 +18695,22 @@ "Interval": { "$ref": "#/definitions/Duration" }, + "Labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Limit": { + "type": "integer", + "format": "int64" + }, "Name": { "type": "string" }, + "QueryOffset": { + "type": "string" + }, "Rules": { "type": "array", "items": { @@ -18804,6 +18824,10 @@ "type": "boolean", "example": false }, + "keep_firing_for": { + "type": "string", + "format": "duration" + }, "labels": { "type": "object", "additionalProperties": { @@ -22869,7 +22893,6 @@ } }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "type": "array", "items": { "type": "object", diff --git a/public/openapi3.json b/public/openapi3.json index 9440b5efd7d..d89f792df71 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -2715,6 +2715,9 @@ "isPaused": { "type": "boolean" }, + "keepFiringFor": { + "$ref": "#/components/schemas/Duration" + }, "labels": { "additionalProperties": { "type": "string" @@ -2975,6 +2978,10 @@ "health": { "type": "string" }, + "keepFiringFor": { + "format": "double", + "type": "number" + }, "labels": { "$ref": "#/components/schemas/Labels" }, @@ -8750,9 +8757,22 @@ "Interval": { "$ref": "#/components/schemas/Duration" }, + "Labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "Limit": { + "format": "int64", + "type": "integer" + }, "Name": { "type": "string" }, + "QueryOffset": { + "type": "string" + }, "Rules": { "items": { "$ref": "#/components/schemas/PrometheusRule" @@ -8855,6 +8875,10 @@ "example": false, "type": "boolean" }, + "keep_firing_for": { + "format": "duration", + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" @@ -12930,7 +12954,6 @@ "type": "object" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/components/schemas/gettableAlert" }, From 163546d40f40b84fb2521a53804c2596be0af553 Mon Sep 17 00:00:00 2001 From: Ieva Date: Thu, 20 Mar 2025 17:38:09 +0000 Subject: [PATCH 15/79] RBAC: Remove dashboard guardians pt 1 (#102314) * replace the usage of dashboard guardians with calling AC evaluators or checking access in middleware * linting fixes * fix test * more test fixes * remove a todo comment --- pkg/api/api.go | 25 +- pkg/api/dashboard.go | 96 +- pkg/api/dashboard_test.go | 141 +- .../accesscontrol/accesscontrol_test.go | 10 +- .../annotationsimpl/annotations_test.go | 11 +- .../dashboards/service/dashboard_service.go | 110 +- .../dashboard_service_integration_test.go | 1444 ++++++++--------- .../service/dashboard_service_test.go | 22 +- .../publicdashboards/service/service_test.go | 9 +- 9 files changed, 772 insertions(+), 1096 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 77efde07038..3aefe35ad22 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -462,22 +462,24 @@ func (hs *HTTPServer) registerRoutes() { // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { - dashboardRoute.Get("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsRead)), routing.Wrap(hs.GetDashboard)) + dashUIDScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(ac.Parameter(":uid")) + + dashboardRoute.Get("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsRead, dashUIDScope)), routing.Wrap(hs.GetDashboard)) if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { - dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.SoftDeleteDashboard)) + dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete, dashUIDScope)), routing.Wrap(hs.SoftDeleteDashboard)) } else { - dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.DeleteDashboardByUID)) + dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete, dashUIDScope)), routing.Wrap(hs.DeleteDashboardByUID)) } dashboardRoute.Group("/uid/:uid", func(dashUidRoute routing.RouteRegister) { - dashUidRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersions)) - dashUidRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.RestoreDashboardVersion)) - dashUidRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersion)) + dashUidRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.GetDashboardVersions)) + dashUidRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.RestoreDashboardVersion)) + dashUidRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.GetDashboardVersion)) if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { - dashUidRoute.Patch("/trash", reqOrgAdmin, routing.Wrap(hs.RestoreDeletedDashboard)) - dashUidRoute.Delete("/trash", reqOrgAdmin, routing.Wrap(hs.HardDeleteDashboardByUID)) + dashUidRoute.Patch("/trash", reqOrgAdmin, authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.RestoreDeletedDashboard)) + dashUidRoute.Delete("/trash", reqOrgAdmin, authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete, dashUIDScope)), routing.Wrap(hs.HardDeleteDashboardByUID)) } dashUidRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) { @@ -497,9 +499,10 @@ func (hs *HTTPServer) registerRoutes() { // Deprecated: use /uid/:uid API instead. dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) { - dashIdRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersions)) - dashIdRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersion)) - dashIdRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.RestoreDashboardVersion)) + dashIDScope := dashboards.ScopeDashboardsProvider.GetResourceScope(ac.Parameter(":dashboardId")) + dashIdRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashIDScope)), routing.Wrap(hs.GetDashboardVersions)) + dashIdRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashIDScope)), routing.Wrap(hs.GetDashboardVersion)) + dashIdRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashIDScope)), routing.Wrap(hs.RestoreDashboardVersion)) dashIdRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) { dashboardPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionDashboardsPermissionsRead)), routing.Wrap(hs.GetDashboardPermissionList)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index c1c61753676..704d736f371 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -27,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" pref "github.com/grafana/grafana/pkg/services/preference" publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" @@ -141,18 +140,21 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response dash.Data.Set("id", dash.ID) } } - guardian, err := guardian.NewByDashboard(ctx, dash, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - if canView, err := guardian.CanView(); err != nil || !canView { - return dashboardGuardianResponse(err) + dashScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID) + writeEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashScope) + canSave, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, writeEvaluator) + canEdit := canSave + //nolint:staticcheck // ViewersCanEdit is deprecated but still used for backward compatibility + if hs.Cfg.ViewersCanEdit { + canEdit = true } - canEdit, _ := guardian.CanEdit() - canSave, _ := guardian.CanSave() - canAdmin, _ := guardian.CanAdmin() - canDelete, _ := guardian.CanDelete() + deleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashScope) + canDelete, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, deleteEvaluator) + adminEvaluator := accesscontrol.EvalAll( + accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsRead, dashScope), + accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsWrite, dashScope)) + canAdmin, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, adminEvaluator) isStarred, err := hs.isDashboardStarredByUser(c, dash.ID) if err != nil { @@ -369,15 +371,6 @@ func (hs *HTTPServer) RestoreDeletedDashboard(c *contextmodel.ReqContext) respon return response.Error(http.StatusNotFound, "Dashboard not found", err) } - guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - - if canRestore, err := guardian.CanSave(); err != nil || !canRestore { - return dashboardGuardianResponse(err) - } - err = hs.DashboardService.RestoreDashboard(c.Req.Context(), dash, c.SignedInUser, cmd.FolderUID) if err != nil { var dashboardErr dashboards.DashboardErr @@ -417,16 +410,7 @@ func (hs *HTTPServer) SoftDeleteDashboard(c *contextmodel.ReqContext) response.R return rsp } - guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - - if canDelete, err := guardian.CanDelete(); err != nil || !canDelete { - return dashboardGuardianResponse(err) - } - - err = hs.DashboardService.SoftDeleteDashboard(c.Req.Context(), c.SignedInUser.GetOrgID(), uid) + err := hs.DashboardService.SoftDeleteDashboard(c.Req.Context(), c.SignedInUser.GetOrgID(), uid) if err != nil { var dashboardErr dashboards.DashboardErr if ok := errors.As(err, &dashboardErr); ok { @@ -498,21 +482,12 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo } } - guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - - if canDelete, err := guardian.CanDelete(); err != nil || !canDelete { - return dashboardGuardianResponse(err) - } - if dash.IsFolder { return response.Error(http.StatusBadRequest, "Use folders endpoint for deleting folders.", nil) } // disconnect all library elements for this dashboard - err = hs.LibraryElementService.DisconnectElementsFromDashboard(c.Req.Context(), dash.ID) + err := hs.LibraryElementService.DisconnectElementsFromDashboard(c.Req.Context(), dash.ID) if err != nil { hs.log.Error( "Failed to disconnect library elements", @@ -840,14 +815,6 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response. return rsp } - guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - if canSave, err := guardian.CanSave(); err != nil || !canSave { - return dashboardGuardianResponse(err) - } - query := dashver.ListDashboardVersionsQuery{ OrgID: c.SignedInUser.GetOrgID(), DashboardID: dash.ID, @@ -959,15 +926,6 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R return rsp } - guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - - if canSave, err := guardian.CanSave(); err != nil || !canSave { - return dashboardGuardianResponse(err) - } - version, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Err(err) @@ -1027,22 +985,15 @@ func (hs *HTTPServer) CalculateDashboardDiff(c *contextmodel.ReqContext) respons if err := web.Bind(c.Req, &apiOptions); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - guardianBase, err := guardian.New(c.Req.Context(), apiOptions.Base.DashboardId, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - if canSave, err := guardianBase.CanSave(); err != nil || !canSave { + evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(apiOptions.Base.DashboardId, 10))) + if canWrite, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canWrite { return dashboardGuardianResponse(err) } if apiOptions.Base.DashboardId != apiOptions.New.DashboardId { - guardianNew, err := guardian.New(c.Req.Context(), apiOptions.New.DashboardId, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - - if canSave, err := guardianNew.CanSave(); err != nil || !canSave { + evaluator = accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(apiOptions.New.DashboardId, 10))) + if canWrite, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canWrite { return dashboardGuardianResponse(err) } } @@ -1159,15 +1110,6 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) respon return rsp } - guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return response.Err(err) - } - - if canSave, err := guardian.CanSave(); err != nil || !canSave { - return dashboardGuardianResponse(err) - } - versionQuery := dashver.GetDashboardVersionQuery{DashboardID: dashID, DashboardUID: dash.UID, Version: apiCmd.Version, OrgID: c.SignedInUser.GetOrgID()} version, err := hs.dashboardVersionService.Get(c.Req.Context(), &versionQuery) if err != nil { diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 3ed18a073e8..b715e17481f 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" @@ -327,12 +328,16 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) { hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) hs.starService = startest.NewStarServiceFake() - hs.dashboardVersionService = &dashvertest.FakeDashboardVersionService{ - ExpectedListDashboarVersions: []*dashver.DashboardVersionDTO{}, - ExpectedDashboardVersion: &dashver.DashboardVersionDTO{}, + expectedDashVersions := []*dashver.DashboardVersionDTO{ + {Data: simplejson.NewFromAny(map[string]any{"title": "Dash"})}, + {Data: simplejson.NewFromAny(map[string]any{"title": "Dash updated"})}, } - guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger()) + hs.dashboardVersionService = &dashvertest.FakeDashboardVersionService{ + ExpectedListDashboarVersions: []*dashver.DashboardVersionDTO{}, + ExpectedDashboardVersions: expectedDashVersions, + ExpectedDashboardVersion: &dashver.DashboardVersionDTO{}, + } }) } @@ -344,6 +349,17 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) { return server.Send(webtest.RequestWithSignedInUser(server.NewGetRequest("/api/dashboards/uid/1/versions"), userWithPermissions(1, permissions))) } + calculateDiff := func(server *webtest.Server, permissions []accesscontrol.Permission) (*http.Response, error) { + cmd := &dtos.CalculateDiffOptions{ + Base: dtos.CalculateDiffTarget{DashboardId: 1, Version: 1}, + New: dtos.CalculateDiffTarget{DashboardId: 1, Version: 2}, + DiffType: "json", + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + return server.SendJSON(webtest.RequestWithSignedInUser(server.NewPostRequest("/api/dashboards/calculate-diff", bytes.NewReader(jsonBytes)), userWithPermissions(1, permissions))) + } + t.Run("Should not be able to list dashboard versions without correct permission", func(t *testing.T) { server := setup() @@ -363,7 +379,6 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) { server := setup() permissions := []accesscontrol.Permission{ - {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:1"}, {Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:1"}, } @@ -378,6 +393,28 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) { require.NoError(t, res.Body.Close()) }) + + t.Run("Should be able to diff dashboards with correct permissions", func(t *testing.T) { + server := setup() + + permissions := []accesscontrol.Permission{ + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + } + + res, err := calculateDiff(server, permissions) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) + + t.Run("Should not be able to diff dashboards without permissions", func(t *testing.T) { + server := setup() + + res, err := calculateDiff(server, []accesscontrol.Permission{}) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, res.StatusCode) + require.NoError(t, res.Body.Close()) + }) } func TestDashboardAPIEndpoint(t *testing.T) { @@ -527,39 +564,6 @@ func TestDashboardAPIEndpoint(t *testing.T) { }), }, } - sqlmock := dbtest.NewFakeDB() - cmd := dtos.CalculateDiffOptions{ - Base: dtos.CalculateDiffTarget{ - DashboardId: 1, - Version: 1, - }, - New: dtos.CalculateDiffTarget{ - DashboardId: 2, - Version: 2, - }, - DiffType: "basic", - } - - t.Run("when user does not have permission", func(t *testing.T) { - role := org.RoleViewer - postDiffScenario(t, "When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) { - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: false}) - - callPostDashboard(sc) - assert.Equal(t, http.StatusForbidden, sc.resp.Code) - }, sqlmock, fakeDashboardVersionService) - }) - - t.Run("when user does have permission", func(t *testing.T) { - role := org.RoleAdmin - postDiffScenario(t, "When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) { - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - // This test shouldn't hit GetDashboardACLInfoList, so no setup needed - sc.dashboardVersionService = fakeDashboardVersionService - callPostDashboard(sc) - assert.Equal(t, http.StatusOK, sc.resp.Code) - }, sqlmock, fakeDashboardVersionService) - }) }) t.Run("Given dashboard in folder being restored should restore to folder", func(t *testing.T) { @@ -588,11 +592,6 @@ func TestDashboardAPIEndpoint(t *testing.T) { }, } mockSQLStore := dbtest.NewFakeDB() - origNewGuardian := guardian.New - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - t.Cleanup(func() { - guardian.New = origNewGuardian - }) restoreDashboardVersionScenario(t, "When calling POST on", "/api/dashboards/id/1/restore", "/api/dashboards/id/:dashboardId/restore", dashboardService, fakeDashboardVersionService, cmd, func(sc *scenarioContext) { @@ -648,7 +647,6 @@ func TestDashboardAPIEndpoint(t *testing.T) { require.NoError(t, err) qResult := &dashboards.Dashboard{ID: 1, Data: dataValue} dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) { fakeProvisioningService := provisioning.NewProvisioningServiceMock(context.Background()) @@ -678,7 +676,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { LibraryElementService: &libraryelementsfake.LibraryElementService{}, dashboardProvisioningService: mockDashboardProvisioningService{}, SQLStore: mockSQLStore, - AccessControl: accesscontrolmock.New(), + AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, DashboardService: dashboardService, Features: featuremgmt.WithFeatures(), starService: startest.NewStarServiceFake(), @@ -710,7 +708,6 @@ func TestDashboardAPIEndpoint(t *testing.T) { Data: dataValue, } dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil) - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) { hs := &HTTPServer{ @@ -718,7 +715,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &libraryelementsfake.LibraryElementService{}, SQLStore: mockSQLStore, - AccessControl: accesscontrolmock.New(), + AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, DashboardService: dashboardService, Features: featuremgmt.WithFeatures(), starService: startest.NewStarServiceFake(), @@ -753,7 +750,7 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { Cfg: cfg, pluginStore: &pluginstore.FakePluginStore{}, SQLStore: mockSQLStore, - AccessControl: accesscontrolmock.New(), + AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, Features: featuremgmt.WithFeatures(), DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, @@ -765,13 +762,8 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { } } - setUp := func() { - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - } - loggedInUserScenarioWithRole(t, "When user exists and calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", org.RoleEditor, func(sc *scenarioContext) { - setUp() fakeDashboardVersionService.ExpectedListDashboarVersions = []*dashver.DashboardVersionDTO{ { Version: 1, @@ -797,7 +789,6 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When user does not exist and calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", org.RoleEditor, func(sc *scenarioContext) { - setUp() fakeDashboardVersionService.ExpectedListDashboarVersions = []*dashver.DashboardVersionDTO{ { Version: 1, @@ -823,7 +814,6 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When failing to get user and calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", org.RoleEditor, func(sc *scenarioContext) { - setUp() fakeDashboardVersionService.ExpectedListDashboarVersions = []*dashver.DashboardVersionDTO{ { Version: 1, @@ -978,47 +968,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s }) } -func postDiffScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions, - role org.RoleType, fn scenarioFunc, sqlmock db.DB, fakeDashboardVersionService *dashvertest.FakeDashboardVersionService, -) { - t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - cfg := setting.NewCfg() - - dashSvc := dashboards.NewFakeDashboardService(t) - hs := HTTPServer{ - Cfg: cfg, - ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), - Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: quotatest.New(false, nil), - LibraryPanelService: &mockLibraryPanelService{}, - LibraryElementService: &libraryelementsfake.LibraryElementService{}, - SQLStore: sqlmock, - dashboardVersionService: fakeDashboardVersionService, - Features: featuremgmt.WithFeatures(), - DashboardService: dashSvc, - tracer: tracing.InitializeTracerForTest(), - } - - sc := setupScenarioContext(t, url) - sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { - c.Req.Body = mockRequestBody(cmd) - c.Req.Header.Add("Content-Type", "application/json") - sc.context = c - sc.context.SignedInUser = &user.SignedInUser{ - OrgID: testOrgID, - UserID: testUserID, - } - sc.context.OrgRole = role - - return hs.CalculateDashboardDiff(c) - }) - - sc.m.Post(routePattern, sc.defaultHandler) - - fn(sc) - }) -} - func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string, mock *dashboards.FakeDashboardService, fakeDashboardVersionService *dashvertest.FakeDashboardVersionService, cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc, sqlStore db.DB, diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go index 3dc79fe876f..1bff50c2dd3 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol_test.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/testutil" @@ -22,7 +22,6 @@ import ( dashboardsservice "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" @@ -42,16 +41,13 @@ func TestIntegrationAuthorize(t *testing.T) { } sql, cfg := db.InitTestDBWithCfg(t) - origNewDashboardGuardian := guardian.New - defer func() { guardian.New = origNewDashboardGuardian }() - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) folderStore := folderimpl.ProvideDashboardFolderStore(sql) fStore := folderimpl.ProvideStore(sql) dashStore, err := database.ProvideDashboardStore(sql, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql)) require.NoError(t, err) - ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) + ac := actest.FakeAccessControl{ExpectedEvaluate: true} folderSvc := folderimpl.ProvideService( - fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, + fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, nil, sql, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService()) dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(), ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService()) diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index 0615023b430..145c94c0748 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/testutil" @@ -54,16 +54,13 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { features := featuremgmt.WithFeatures() tagService := tagimpl.ProvideService(sql) ruleStore := alertingStore.SetupStoreForTesting(t, sql) - origNewDashboardGuardian := guardian.New - defer func() { guardian.New = origNewDashboardGuardian }() - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{}) folderStore := folderimpl.ProvideDashboardFolderStore(sql) fStore := folderimpl.ProvideStore(sql) dashStore, err := database.ProvideDashboardStore(sql, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql)) require.NoError(t, err) - ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) + ac := actest.FakeAccessControl{ExpectedEvaluate: true} folderSvc := folderimpl.ProvideService( - fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, + fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, nil, sql, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService()) dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(), ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService()) @@ -242,7 +239,7 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { guardian.New = origNewGuardian }) - ac := acimpl.ProvideAccessControl(features) + ac := actest.FakeAccessControl{ExpectedEvaluate: true} fStore := folderimpl.ProvideStore(sql) folderStore := folderimpl.ProvideDashboardFolderStore(sql) folderSvc := folderimpl.ProvideService( diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 53164cda375..a8516b0d594 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -43,7 +43,6 @@ import ( dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/quota" @@ -413,14 +412,24 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d } if isParentFolderChanged { - // Check that the user is allowed to add a dashboard to the folder - guardian, err := guardian.NewByDashboard(ctx, dash, dto.OrgID, dto.User) - if err != nil { - return nil, err + if canCreate, err := dr.canCreateDashboard(ctx, dto.User, dash); err != nil || !canCreate { + if err != nil { + return nil, err + } + return nil, dashboards.ErrDashboardUpdateAccessDenied } + } + + if dash.ID == 0 { metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - // nolint:staticcheck - if canSave, err := guardian.CanCreate(dash.FolderID, dash.IsFolder); err != nil || !canSave { + if canCreate, err := dr.canCreateDashboard(ctx, dto.User, dash); err != nil || !canCreate { + if err != nil { + return nil, err + } + return nil, dashboards.ErrDashboardUpdateAccessDenied + } + } else { + if canSave, err := dr.canSaveDashboard(ctx, dto.User, dash); err != nil || !canSave { if err != nil { return nil, err } @@ -439,29 +448,6 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d } } - guard, err := getGuardianForSavePermissionCheck(ctx, dash, dto.User) - if err != nil { - return nil, err - } - - if dash.ID == 0 { - metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - // nolint:staticcheck - if canCreate, err := guard.CanCreate(dash.FolderID, dash.IsFolder); err != nil || !canCreate { - if err != nil { - return nil, err - } - return nil, dashboards.ErrDashboardUpdateAccessDenied - } - } else { - if canSave, err := guard.CanSave(); err != nil || !canSave { - if err != nil { - return nil, err - } - return nil, dashboards.ErrDashboardUpdateAccessDenied - } - } - var userID int64 if id, err := identity.UserIdentifier(dto.User.GetID()); err == nil { userID = id @@ -561,6 +547,30 @@ func (dr *DashboardServiceImpl) ValidateDashboardBeforeSave(ctx context.Context, return isParentFolderChanged, nil } +func (dr *DashboardServiceImpl) canSaveDashboard(ctx context.Context, user identity.Requester, dash *dashboards.Dashboard) (bool, error) { + action := dashboards.ActionDashboardsWrite + if dash.IsFolder { + action = dashboards.ActionFoldersWrite + } + scope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID) + if dash.IsFolder { + scope = dashboards.ScopeFoldersProvider.GetResourceScopeUID(dash.UID) + } + return dr.ac.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope)) +} + +func (dr *DashboardServiceImpl) canCreateDashboard(ctx context.Context, user identity.Requester, dash *dashboards.Dashboard) (bool, error) { + action := dashboards.ActionDashboardsCreate + if dash.IsFolder { + action = dashboards.ActionFoldersCreate + } + scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(dash.FolderUID) + if dash.FolderUID == "" { + scope = dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID) + } + return dr.ac.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope)) +} + // waitForSearchQuery waits for the search query to return the expected number of hits. // Since US doesn't offer search-after-write guarantees, we can use this to wait after writes until the indexer is up to date. func (dr *DashboardServiceImpl) waitForSearchQuery(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery, maxRetries int, expectedHits int64) error { @@ -620,46 +630,6 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context. return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd) } -// getGuardianForSavePermissionCheck returns the guardian to be used for checking permission of dashboard -// It replaces deleted Dashboard.GetDashboardIdForSavePermissionCheck() -func getGuardianForSavePermissionCheck(ctx context.Context, d *dashboards.Dashboard, user identity.Requester) (guardian.DashboardGuardian, error) { - ctx, span := tracer.Start(ctx, "dashboards.service.getGuardianForSavePermissionCheck") - defer span.End() - - newDashboard := d.ID == 0 - - if newDashboard { - // if it's a new dashboard/folder check the parent folder permissions - metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - guard, err := guardian.NewByFolder(ctx, &folder.Folder{ - ID: d.FolderID, // nolint:staticcheck - OrgID: d.OrgID, - }, d.OrgID, user) - if err != nil { - return nil, err - } - return guard, nil - } - - if d.IsFolder { - guard, err := guardian.NewByFolder(ctx, &folder.Folder{ - ID: d.ID, // nolint:staticcheck - UID: d.UID, - OrgID: d.OrgID, - }, d.OrgID, user) - if err != nil { - return nil, err - } - return guard, nil - } - - guard, err := guardian.NewByDashboard(ctx, d, d.OrgID, user) - if err != nil { - return nil, err - } - return guard, nil -} - func validateDashboardRefreshInterval(minRefreshInterval string, dash *dashboards.Dashboard) error { if minRefreshInterval == "" { return nil diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 221ade24f72..e22a5ae0cd4 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -8,13 +8,12 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apiserver/client" @@ -23,7 +22,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/quota/quotatest" @@ -49,809 +47,710 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { t.Run("Given saved folders and dashboards in organization A", func(t *testing.T) { // Basic validation tests - permissionScenario(t, "When saving a dashboard with non-existing id", true, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": float64(123412321), - "title": "Expect error", - }), - } + permissionScenario(t, "When saving a dashboard with non-existing id", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": float64(123412321), + "title": "Expect error", + }), + } - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardNotFound, err) - }) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardNotFound, err) + }) // Given other organization t.Run("Given organization B", func(t *testing.T) { const otherOrgId int64 = 2 - permissionScenario(t, "When creating a dashboard with same id as dashboard in organization A", - true, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: otherOrgId, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "title": "Expect error", - }), - Overwrite: false, - } + permissionScenario(t, "When creating a dashboard with same id as dashboard in organization A", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: otherOrgId, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedDashInFolder.ID, + "title": "Expect error", + }), + Overwrite: false, + } - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardNotFound, err) - }) - - permissionScenario(t, "When creating a dashboard with same uid as dashboard in organization A, it should create a new dashboard in org B", - true, func(t *testing.T, sc *permissionScenarioContext) { - const otherOrgId int64 = 2 - cmd := dashboards.SaveDashboardCommand{ - OrgID: otherOrgId, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Dash with existing uid in other org", - }), - Overwrite: false, - } - - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - OrgID: otherOrgId, - UID: sc.savedDashInFolder.UID, - }) - require.NoError(t, err) - }) - }) - - t.Run("Given user has no permission to save", func(t *testing.T) { - const canSave = false - - permissionScenario(t, "When creating a new dashboard in the General folder", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - sqlStore := db.InitTestDB(t) - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dash", - }), - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sqlStore) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, "", sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When creating a new dashboard in other folder, it should create dashboard guardian for other folder with correct arguments and rsult in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dash", - }), - FolderUID: sc.otherSavedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.otherSavedFolder.ID, sc.dashboardGuardianMock.DashID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When creating a new dashboard by existing title in folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - t.Skip() - - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": sc.savedDashInFolder.Title, - }), - FolderUID: sc.savedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When creating a new dashboard by existing UID in folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "New dash", - }), - FolderUID: sc.savedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When updating a dashboard by existing id in the General folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "Dash", - }), - FolderUID: sc.savedDashInGeneralFolder.FolderUID, - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInGeneralFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When updating a dashboard by existing id in other folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "title": "Dash", - }), - FolderUID: sc.savedDashInFolder.FolderUID, - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When moving a dashboard by existing ID to other folder from General folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "Dash", - }), - FolderUID: sc.otherSavedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInGeneralFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When moving a dashboard by existing id to the General folder from other folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "title": "Dash", - }), - FolderUID: "", - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When moving a dashboard by existing uid to other folder from General folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInGeneralFolder.UID, - "title": "Dash", - }), - FolderUID: sc.otherSavedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInGeneralFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) - }) - - permissionScenario(t, "When moving a dashboard by existing UID to the General folder from other folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Dash", - }), - FolderUID: "", - UserID: 10000, - Overwrite: true, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID()) - require.NoError(t, err) - - assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID) - assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID) - assert.Equal(t, cmd.UserID, userID) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardNotFound, err) + }) + + permissionScenario(t, "When creating a dashboard with same uid as dashboard in organization A, it should create a new dashboard in org B", func(t *testing.T, sc *permissionScenarioContext) { + const otherOrgId int64 = 2 + cmd := dashboards.SaveDashboardCommand{ + OrgID: otherOrgId, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInFolder.UID, + "title": "Dash with existing uid in other org", + }), + Overwrite: false, + } + + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + OrgID: otherOrgId, + UID: sc.savedDashInFolder.UID, }) + require.NoError(t, err) + }) }) t.Run("Given user has permission to save", func(t *testing.T) { - const canSave = true - t.Run("and overwrite flag is set to false", func(t *testing.T) { const shouldOverwrite = false - permissionScenario(t, "When creating a dashboard in General folder with same name as dashboard in other folder", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When creating a dashboard in General folder with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedDashInFolder.Title, + }), + FolderUID: "", + Overwrite: shouldOverwrite, + } - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - - require.NoError(t, err) + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: res.ID, + OrgID: cmd.OrgID, }) - permissionScenario(t, "When creating a dashboard in other folder with same name as dashboard in General folder", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInGeneralFolder.Title, - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } + require.NoError(t, err) + }) - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) + permissionScenario(t, "When creating a dashboard in other folder with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedDashInGeneralFolder.Title, + }), + FolderUID: sc.savedFolder.UID, + Overwrite: shouldOverwrite, + } - assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID) + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) + assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID) + + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: res.ID, + OrgID: cmd.OrgID, + }) + require.NoError(t, err) + }) + + permissionScenario(t, "When creating a folder with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedDashInFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } + + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + + assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID) + assert.True(t, res.IsFolder) + + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: res.ID, + OrgID: cmd.OrgID, + }) + require.NoError(t, err) + }) + + permissionScenario(t, "When saving a dashboard without id and uid and unique title in folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "Dash without id and uid", + }), + Overwrite: shouldOverwrite, + } + + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + + assert.Greater(t, res.ID, int64(0)) + assert.NotEmpty(t, res.UID) + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: res.ID, + OrgID: cmd.OrgID, + }) + require.NoError(t, err) + }) + + permissionScenario(t, "When saving a dashboard when dashboard id is zero ", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": 0, + "title": "Dash with zero id", + }), + Overwrite: shouldOverwrite, + } + + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: res.ID, + OrgID: cmd.OrgID, + }) + require.NoError(t, err) + }) + + permissionScenario(t, "When saving a dashboard in non-existing folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "Expect error", + }), + FolderUID: "123412321", + Overwrite: shouldOverwrite, + } + + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrFolderNotFound, err) + }) + + permissionScenario(t, "When updating an existing dashboard by id without current version", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedDashInGeneralFolder.ID, + "title": "test dash 23", + }), + FolderUID: sc.savedFolder.UID, + Overwrite: shouldOverwrite, + } + + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) + }) + + permissionScenario(t, "When updating an existing dashboard by id with current version", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedDashInGeneralFolder.ID, + "title": "Updated title", + "version": sc.savedDashInGeneralFolder.Version, + }), + FolderUID: sc.savedFolder.UID, + Overwrite: shouldOverwrite, + } + + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: sc.savedDashInGeneralFolder.ID, + OrgID: cmd.OrgID, }) - permissionScenario(t, "When creating a folder with same name as dashboard in other folder", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } + require.NoError(t, err) + }) - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) + permissionScenario(t, "When updating an existing dashboard by uid without current version", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInFolder.UID, + "title": "test dash 23", + }), + FolderUID: "", + Overwrite: shouldOverwrite, + } - assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID) - assert.True(t, res.IsFolder) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) + }) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) + permissionScenario(t, "When updating an existing dashboard by uid with current version", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInFolder.UID, + "title": "Updated title", + "version": sc.savedDashInFolder.Version, + }), + FolderUID: "", + Overwrite: shouldOverwrite, + } + + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: sc.savedDashInFolder.ID, + OrgID: cmd.OrgID, }) + require.NoError(t, err) + }) - permissionScenario(t, "When saving a dashboard without id and uid and unique title in folder", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dash without id and uid", - }), - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedDashInFolder.Title, + }), + FolderUID: sc.savedDashInFolder.FolderUID, + Overwrite: shouldOverwrite, + } - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NoError(t, err) + }) - assert.Greater(t, res.ID, int64(0)) - assert.NotEmpty(t, res.UID) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) + permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedDashInGeneralFolder.Title, + }), + FolderUID: sc.savedDashInGeneralFolder.FolderUID, + Overwrite: shouldOverwrite, + } - permissionScenario(t, "When saving a dashboard when dashboard id is zero ", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": 0, - "title": "Dash with zero id", - }), - Overwrite: shouldOverwrite, - } + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NoError(t, err) + }) - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) + permissionScenario(t, "When creating a folder with same name as existing folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When saving a dashboard in non-existing folder", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Expect error", - }), - FolderUID: "123412321", - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrFolderNotFound, err) - }) - - permissionScenario(t, "When updating an existing dashboard by id without current version", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "test dash 23", - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) - }) - - permissionScenario(t, "When updating an existing dashboard by id with current version", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "Updated title", - "version": sc.savedDashInGeneralFolder.Version, - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } - - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInGeneralFolder.ID, - OrgID: cmd.OrgID, - }) - - require.NoError(t, err) - }) - - permissionScenario(t, "When updating an existing dashboard by uid without current version", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "test dash 23", - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) - }) - - permissionScenario(t, "When updating an existing dashboard by uid with current version", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Updated title", - "version": sc.savedDashInFolder.Version, - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } - - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - FolderUID: sc.savedDashInFolder.FolderUID, - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", - canSave, func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInGeneralFolder.Title, - }), - FolderUID: sc.savedDashInGeneralFolder.FolderUID, - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a folder with same name as existing folder", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedFolder.Title, - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.NoError(t, err) - }) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NoError(t, err) + }) }) t.Run("and overwrite flag is set to true", func(t *testing.T) { const shouldOverwrite = true - permissionScenario(t, "When updating an existing dashboard by id without current version", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "Updated title", - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating an existing dashboard by id without current version", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedDashInGeneralFolder.ID, + "title": "Updated title", + }), + FolderUID: sc.savedFolder.UID, + Overwrite: shouldOverwrite, + } - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInGeneralFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: sc.savedDashInGeneralFolder.ID, + OrgID: cmd.OrgID, }) + require.NoError(t, err) + }) - permissionScenario(t, "When updating an existing dashboard by uid without current version", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Updated title", - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating an existing dashboard by uid without current version", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInFolder.UID, + "title": "Updated title", + }), + FolderUID: "", + Overwrite: shouldOverwrite, + } - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: sc.savedDashInFolder.ID, + OrgID: cmd.OrgID, }) + require.NoError(t, err) + }) - permissionScenario(t, "When updating uid for existing dashboard using id", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "uid": "new-uid", - "title": sc.savedDashInFolder.Title, - }), - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating uid for existing dashboard using id", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedDashInFolder.ID, + "uid": "new-uid", + "title": sc.savedDashInFolder.Title, + }), + Overwrite: shouldOverwrite, + } - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) - assert.Equal(t, sc.savedDashInFolder.ID, res.ID) - assert.Equal(t, "new-uid", res.UID) + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + assert.Equal(t, sc.savedDashInFolder.ID, res.ID) + assert.Equal(t, "new-uid", res.UID) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: sc.savedDashInFolder.ID, + OrgID: cmd.OrgID, }) + require.NoError(t, err) + }) - permissionScenario(t, "When updating uid to an existing uid for existing dashboard using id", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "uid": sc.savedDashInGeneralFolder.UID, - "title": sc.savedDashInFolder.Title, - }), - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating uid to an existing uid for existing dashboard using id", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedDashInFolder.ID, + "uid": sc.savedDashInGeneralFolder.UID, + "title": sc.savedDashInFolder.Title, + }), + Overwrite: shouldOverwrite, + } - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err) + }) + + permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { + t.Skip() + + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedDashInFolder.Title, + }), + FolderUID: sc.savedDashInFolder.FolderUID, + Overwrite: shouldOverwrite, + } + + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + assert.Equal(t, sc.savedDashInFolder.ID, res.ID) + assert.Equal(t, sc.savedDashInFolder.UID, res.UID) + + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: res.ID, + OrgID: cmd.OrgID, }) + require.NoError(t, err) + }) - permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - t.Skip() + permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) { + t.Skip() - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - FolderUID: sc.savedDashInFolder.FolderUID, - Overwrite: shouldOverwrite, - } + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": nil, + "title": sc.savedDashInGeneralFolder.Title, + }), + FolderUID: sc.savedDashInGeneralFolder.FolderUID, + Overwrite: shouldOverwrite, + } - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) - assert.Equal(t, sc.savedDashInFolder.ID, res.ID) - assert.Equal(t, sc.savedDashInFolder.UID, res.UID) + res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NotNil(t, res) + assert.Equal(t, sc.savedDashInGeneralFolder.ID, res.ID) + assert.Equal(t, sc.savedDashInGeneralFolder.UID, res.UID) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) + _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ + ID: res.ID, + OrgID: cmd.OrgID, }) + require.NoError(t, err) + }) - permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - t.Skip() + permissionScenario(t, "When updating existing folder to a dashboard using id", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedFolder.ID, + "title": "new title", + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInGeneralFolder.Title, - }), - FolderUID: sc.savedDashInGeneralFolder.FolderUID, - Overwrite: shouldOverwrite, - } + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) + }) - res := callSaveWithResult(t, cmd, sc.sqlStore) - require.NotNil(t, res) - assert.Equal(t, sc.savedDashInGeneralFolder.ID, res.ID) - assert.Equal(t, sc.savedDashInGeneralFolder.UID, res.UID) + permissionScenario(t, "When updating existing dashboard to a folder using id", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "id": sc.savedDashInFolder.ID, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) + }) - permissionScenario(t, "When updating existing folder to a dashboard using id", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedFolder.ID, - "title": "new title", - }), - IsFolder: false, - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating existing folder to a dashboard using uid", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedFolder.UID, + "title": "new title", + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) + }) - permissionScenario(t, "When updating existing dashboard to a folder using id", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating existing dashboard to a folder using uid", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInFolder.UID, + "title": "new folder title", + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) + }) - permissionScenario(t, "When updating existing folder to a dashboard using uid", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedFolder.UID, - "title": "new title", - }), - IsFolder: false, - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating existing folder to a dashboard using title", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": sc.savedFolder.Title, + }), + IsFolder: false, + Overwrite: shouldOverwrite, + } - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NoError(t, err) + }) - permissionScenario(t, "When updating existing dashboard to a folder using uid", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } + permissionScenario(t, "When updating existing dashboard to a folder using title", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: 1, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": sc.savedDashInGeneralFolder.Title, + }), + IsFolder: true, + Overwrite: shouldOverwrite, + } - err := callSaveWithError(t, cmd, sc.sqlStore) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) - - permissionScenario(t, "When updating existing folder to a dashboard using title", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": sc.savedFolder.Title, - }), - IsFolder: false, - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.NoError(t, err) - }) - - permissionScenario(t, "When updating existing dashboard to a folder using title", canSave, - func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": sc.savedDashInGeneralFolder.Title, - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } - - err := callSaveWithError(t, cmd, sc.sqlStore) - require.NoError(t, err) - }) + _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) + require.NoError(t, err) + }) }) }) }) } +func TestIntegrationDashboardServicePermissions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + t.Run("Given saved folders and dashboards in organization A", func(t *testing.T) { + permissionScenario(t, "When creating a new dashboard in the General folder, requires create permissions scoped to the general folder", + func(t *testing.T, sc *permissionScenarioContext) { + sqlStore := db.InitTestDB(t) + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "Dash", + }), + UserID: 10000, + Overwrite: true, + } + + permissions := map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsAll}, + }, + } + _, err := callSaveWithResult(t, cmd, sqlStore, permissions) + assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) + + permissions = map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)}, + }, + } + _, err = callSaveWithResult(t, cmd, sqlStore, permissions) + assert.Nil(t, err) + }) + + permissionScenario(t, "When creating a new dashboard in other folder, requires create permissions scoped to the other folder", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "title": "Dash", + }), + FolderUID: sc.otherSavedFolder.UID, + UserID: 10000, + Overwrite: true, + } + + permissions := map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("different_folder_uid")}, + }, + } + _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) + + permissions = map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)}, + }, + } + _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Nil(t, err) + }) + + permissionScenario(t, "When creating a new dashboard by existing UID in folder, requires write permissions on the existing dashboard", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInFolder.UID, + "title": "New dash", + }), + FolderUID: sc.savedFolder.UID, + UserID: 10000, + Overwrite: true, + } + + permissions := map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID("different_dash_uid")}, + }, + } + _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) + + permissions = map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)}, + }, + } + _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Nil(t, err) + }) + + permissionScenario(t, "When moving a dashboard by existing uid to other folder from General folder, requires dashboard creation permissions on the destination folder and write access to the dashboard", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInGeneralFolder.UID, + "title": "Dash", + }), + FolderUID: sc.otherSavedFolder.UID, + UserID: 10000, + Overwrite: true, + } + + // Perms to write dashboard but not create dashboards in the destination folder + permissions := map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInGeneralFolder.UID)}, + }, + } + _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) + + // Perms to create dashboards in the destination folder but not write the dashboard + permissions = map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)}, + }, + } + _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) + + // Perms to write dashboard and create dashboards in the destination folder + permissions = map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInGeneralFolder.UID)}, + dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)}, + }, + } + _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Nil(t, err) + }) + + permissionScenario(t, "When moving a dashboard by existing uid to the General folder from other folder, requires dashboard creation permissions on the general folder and write access to the dashboard", func(t *testing.T, sc *permissionScenarioContext) { + cmd := dashboards.SaveDashboardCommand{ + OrgID: testOrgID, + Dashboard: simplejson.NewFromAny(map[string]any{ + "uid": sc.savedDashInFolder.UID, + "title": "Dash", + }), + FolderUID: "", + UserID: 10000, + Overwrite: true, + } + + // Perms to write dashboard but not create dashboards in the destination folder + permissions := map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)}, + }, + } + _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) + + // Perms to create dashboards in the destination folder but not write the dashboard + permissions = map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)}, + }, + } + _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) + + // Perms to write dashboard and create dashboards in the destination folder + permissions = map[int64]map[string][]string{ + testOrgID: { + dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)}, + dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)}, + }, + } + _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) + assert.NoError(t, err) + }) + }) +} + type permissionScenarioContext struct { - dashboardGuardianMock *guardian.FakeDashboardGuardian sqlStore db.DB dashboardStore dashboards.Store savedFolder *dashboards.Dashboard @@ -862,14 +761,9 @@ type permissionScenarioContext struct { type permissionScenarioFunc func(t *testing.T, sc *permissionScenarioContext) -func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionScenarioFunc) { +func permissionScenario(t *testing.T, desc string, fn permissionScenarioFunc) { t.Helper() - guardianMock := &guardian.FakeDashboardGuardian{ - CanSaveValue: canSave, - CanViewValue: true, - } - t.Run(desc, func(t *testing.T) { features := featuremgmt.WithFeatures() cfg := setting.NewCfg() @@ -922,7 +816,6 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc ) dashboardService.RegisterDashboardPermissions(dashboardPermissions) require.NoError(t, err) - guardian.InitAccessControlGuardian(cfg, ac, dashboardService, folderService, log.NewNopLogger()) savedFolder := saveTestFolder(t, "Saved folder", testOrgID, sqlStore) savedDashInFolder := saveTestDashboard(t, "Saved dash in folder", testOrgID, savedFolder.UID, sqlStore) @@ -942,14 +835,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc require.False(t, savedDashInFolder.IsFolder) require.NotEmpty(t, savedDashInFolder.UID) - origNewDashboardGuardian := guardian.New - t.Cleanup(func() { - guardian.New = origNewDashboardGuardian - }) - guardian.MockDashboardGuardian(guardianMock) - sc := &permissionScenarioContext{ - dashboardGuardianMock: guardianMock, sqlStore: sqlStore, savedDashInFolder: savedDashInFolder, otherSavedFolder: otherSavedFolder, @@ -962,11 +848,17 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc }) } -func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) *dashboards.Dashboard { +func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB, permissions map[int64]map[string][]string) (*dashboards.Dashboard, error) { t.Helper() features := featuremgmt.WithFeatures() dto := toSaveDashboardDto(cmd) + var ac accesscontrol.AccessControl + ac = actest.FakeAccessControl{ExpectedEvaluate: true} + if permissions != nil { + dto.User = &user.SignedInUser{UserID: cmd.UserID, OrgID: testOrgID, Permissions: permissions} + ac = acimpl.ProvideAccessControl(features) + } cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) @@ -1002,7 +894,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, - actest.FakeAccessControl{}, + ac, folderService, folder.NewFakeStore(), nil, @@ -1017,61 +909,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt ) require.NoError(t, err) service.RegisterDashboardPermissions(dashboardPermissions) - res, err := service.SaveDashboard(context.Background(), &dto, false) - require.NoError(t, err) - - return res -} - -func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) error { - features := featuremgmt.WithFeatures() - dto := toSaveDashboardDto(cmd) - cfg := setting.NewCfg() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - tracer := tracing.InitializeTracerForTest() - publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) - folderStore2 := folderimpl.ProvideStore(sqlStore) - folderService := folderimpl.ProvideService(folderStore2, - actest.FakeAccessControl{ExpectedEvaluate: true}, - bus.ProvideBus(tracer), - dashboardStore, - folderStore, - nil, - sqlStore, - features, - supportbundlestest.NewFakeBundleService(), - publicDashboardFakeService, - cfg, - nil, - tracer, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - ) - service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - featuremgmt.WithFeatures(), - accesscontrolmock.NewMockedPermissionsService(), - actest.FakeAccessControl{}, - folderService, - folder.NewFakeStore(), - nil, - client.MockTestRestConfig{}, - nil, - quotaService, - nil, - nil, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - ) - require.NoError(t, err) - service.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService()) - _, err = service.SaveDashboard(context.Background(), &dto, false) - return err + return service.SaveDashboard(context.Background(), &dto, false) } func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.DB) *dashboards.Dashboard { @@ -1127,7 +965,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string cfg, dashboardStore, folderStore, features, accesscontrolmock.NewMockedPermissionsService(), - actest.FakeAccessControl{}, + actest.FakeAccessControl{ExpectedEvaluate: true}, folderService, folder.NewFakeStore(), nil, @@ -1206,7 +1044,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, - actest.FakeAccessControl{}, + actest.FakeAccessControl{ExpectedEvaluate: true}, folderService, folder.NewFakeStore(), nil, diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 2223eabb9d7..0f68c97d2ec 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -20,13 +20,13 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apiserver/client" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/foldertest" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/publicdashboards" @@ -50,6 +50,7 @@ func TestDashboardService(t *testing.T) { log: log.New("test.logger"), dashboardStore: &fakeStore, folderService: folderSvc, + ac: actest.FakeAccessControl{ExpectedEvaluate: true}, features: featuremgmt.WithFeatures(), publicDashboardService: fakePublicDashboardService, } @@ -57,10 +58,6 @@ func TestDashboardService(t *testing.T) { folderStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(nil, dashboards.ErrFolderNotFound).Once() service.folderStore = &folderStore - origNewDashboardGuardian := guardian.New - defer func() { guardian.New = origNewDashboardGuardian }() - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - t.Run("Save dashboard validation", func(t *testing.T) { dto := &dashboards.SaveDashboardDTO{} @@ -1292,13 +1289,10 @@ func TestSetDefaultPermissionsWhenSavingFolderForProvisionedDashboards(t *testin UID: "general", }, }, + ac: actest.FakeAccessControl{ExpectedEvaluate: true}, log: log.NewNopLogger(), } - origNewDashboardGuardian := guardian.New - defer func() { guardian.New = origNewDashboardGuardian }() - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - cmd := &folder.CreateFolderCommand{ Title: "foo", OrgID: 1, @@ -1326,13 +1320,10 @@ func TestSaveProvisionedDashboard(t *testing.T) { UID: "general", }, }, + ac: actest.FakeAccessControl{ExpectedEvaluate: true}, log: log.NewNopLogger(), } - origNewDashboardGuardian := guardian.New - defer func() { guardian.New = origNewDashboardGuardian }() - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - query := &dashboards.SaveDashboardDTO{ OrgID: 1, User: &user.SignedInUser{UserID: 1}, @@ -1392,12 +1383,9 @@ func TestSaveDashboard(t *testing.T) { folderService: &foldertest.FakeService{ ExpectedFolder: &folder.Folder{}, }, + ac: actest.FakeAccessControl{ExpectedEvaluate: true}, } - origNewDashboardGuardian := guardian.New - defer func() { guardian.New = origNewDashboardGuardian }() - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) - query := &dashboards.SaveDashboardDTO{ OrgID: 1, User: &user.SignedInUser{UserID: 1}, diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 1ae4797a45f..67feb554cf2 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -29,7 +29,6 @@ import ( dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" . "github.com/grafana/grafana/pkg/services/publicdashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" @@ -1392,7 +1391,7 @@ func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { testDB, cfg := db.InitTestDBWithCfg(t) dashStore, err := dashboardsDB.ProvideDashboardStore(testDB, cfg, features, tagimpl.ProvideService(testDB)) require.NoError(t, err) - ac := acmock.New() + ac := actest.FakeAccessControl{ExpectedEvaluate: true} fStore := folderimpl.ProvideStore(testDB) folderPermissions := acmock.NewMockedPermissionsService() @@ -1404,12 +1403,6 @@ func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { dashboardService, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService()) require.NoError(t, err) dashboardService.RegisterDashboardPermissions(&actest.FakePermissionsService{}) - fakeGuardian := &guardian.FakeDashboardGuardian{ - CanSaveValue: true, - CanEditUIDs: []string{}, - CanViewUIDs: []string{}, - } - guardian.MockDashboardGuardian(fakeGuardian) // insert in test data so we can check that permissions are working properly through the dashboard service // this will create 4 dashboards and 3 users From 3ef583aa059821291f7327e0d098bbeeeb823cec Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Thu, 20 Mar 2025 18:44:27 +0100 Subject: [PATCH 16/79] Alerting: Set MissingSeriesEvalsToResolve to 1 for Prometheus-imported rules (#101973) --- pkg/services/ngalert/prom/convert.go | 6 ++++++ pkg/services/ngalert/prom/convert_test.go | 1 + 2 files changed, 7 insertions(+) diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index 12e97106461..b433a2ec76b 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -241,6 +241,12 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom RuleGroup: promGroup.Name, IsPaused: isPaused, Record: record, + + // MissingSeriesEvalsToResolve is set to 1 to match the Prometheus behaviour. + // Prometheus resolves alerts as soon as the series disappears. + // By setting this value to 1 we ensure that the alert is resolved on the first evaluation + // that doesn't have the series. + MissingSeriesEvalsToResolve: util.Pointer(1), } if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 4e92a090b19..479417bc6b8 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -280,6 +280,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) { require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To) require.Equal(t, models.Duration(10*time.Minute+evalOffset), grafanaRule.Data[0].RelativeTimeRange.From) + require.Equal(t, util.Pointer(1), grafanaRule.MissingSeriesEvalsToResolve) originalRuleDefinition, err := yaml.Marshal(promRule) require.NoError(t, err) From 307974f20d555a6ee2bc593fd9a4c6bf76765f3e Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:44:47 +0100 Subject: [PATCH 17/79] Data sources: Improve error messages for grpc errors (#102372) * Data sources: Improve error messages for grpc errors * Improve code comments * Fix lint * Update connection issue message * Update name * Update comment * Update, rename and add test * Update, remove POC change * Fix lint --- .../backendplugin/grpcplugin/client_v2.go | 8 ++++ pkg/plugins/errors.go | 12 ++++++ pkg/plugins/manager/client/client.go | 18 +++++--- pkg/plugins/manager/client/client_test.go | 41 ++++++++++++++----- 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/pkg/plugins/backendplugin/grpcplugin/client_v2.go b/pkg/plugins/backendplugin/grpcplugin/client_v2.go index 7babf11c2df..78d7b0d6dcc 100644 --- a/pkg/plugins/backendplugin/grpcplugin/client_v2.go +++ b/pkg/plugins/backendplugin/grpcplugin/client_v2.go @@ -172,6 +172,14 @@ func (c *ClientV2) QueryData(ctx context.Context, req *backend.QueryDataRequest) return nil, plugins.ErrMethodNotImplemented } + if status.Code(err) == codes.Unavailable { + return nil, plugins.ErrPluginGrpcConnectionUnavailableBase.Errorf("%v", err) + } + + if status.Code(err) == codes.ResourceExhausted { + return nil, plugins.ErrPluginGrpcResourceExhaustedBase.Errorf("%v", err) + } + if errorSource, ok := backend.ErrorSourceFromGrpcStatusError(ctx, err); ok { return nil, handleGrpcStatusError(ctx, errorSource, err) } diff --git a/pkg/plugins/errors.go b/pkg/plugins/errors.go index 1ab785ff7a3..77e35735ee0 100644 --- a/pkg/plugins/errors.go +++ b/pkg/plugins/errors.go @@ -35,4 +35,16 @@ var ( // Exposed as a base error to wrap it with plugin cancelled errors. ErrPluginRequestCanceledErrorBase = errutil.ClientClosedRequest("plugin.requestCanceled", errutil.WithPublicMessage("Plugin request canceled")) + + // ErrPluginGrpcResourceExhaustedBase error returned when a plugin response is larger than the grpc limit. + // Exposed as a base error to wrap it with plugin resource exhausted errors. + ErrPluginGrpcResourceExhaustedBase = errutil.Internal("plugin.resourceExhausted", + errutil.WithPublicMessage("The response is too large. Please try to reduce the time range or narrow down your query to return fewer data points."), + errutil.WithDownstream()) + + // ErrPluginGrpcConnectionUnavailableBase error returned when a plugin connection issue occurs. + // Exposed as a base error to wrap it with plugin connection issue errors. + ErrPluginGrpcConnectionUnavailableBase = errutil.Internal("plugin.connectionUnavailable", + errutil.WithPublicMessage("Data source became unavailable during request. Please try again."), + errutil.WithDownstream()) ) diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go index 7ab5d6a60ff..4f4d19aa6b0 100644 --- a/pkg/plugins/manager/client/client.go +++ b/pkg/plugins/manager/client/client.go @@ -30,6 +30,14 @@ var ( errNilSender = errors.New("sender cannot be nil") ) +// passthroughErrors contains a list of errors that should be returned directly to the caller without wrapping +var passthroughErrors = []error{ + plugins.ErrPluginUnavailable, + plugins.ErrMethodNotImplemented, + plugins.ErrPluginGrpcResourceExhaustedBase, + plugins.ErrPluginGrpcConnectionUnavailableBase, +} + type Service struct { pluginRegistry registry.Service } @@ -52,12 +60,10 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) resp, err := p.QueryData(ctx, req) if err != nil { - if errors.Is(err, plugins.ErrMethodNotImplemented) { - return nil, err - } - - if errors.Is(err, plugins.ErrPluginUnavailable) { - return nil, err + for _, e := range passthroughErrors { + if errors.Is(err, e) { + return nil, err + } } if errors.Is(err, context.Canceled) { diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go index f45761c3649..4964a3b1631 100644 --- a/pkg/plugins/manager/client/client_test.go +++ b/pkg/plugins/manager/client/client_test.go @@ -25,24 +25,39 @@ func TestQueryData(t *testing.T) { t.Run("Non-empty registry", func(t *testing.T) { tcs := []struct { - err error - expectedError error + err error + expectedError error + shouldPassThrough bool }{ { - err: plugins.ErrPluginUnavailable, - expectedError: plugins.ErrPluginUnavailable, + err: plugins.ErrPluginUnavailable, + expectedError: plugins.ErrPluginUnavailable, + shouldPassThrough: true, }, { - err: plugins.ErrMethodNotImplemented, - expectedError: plugins.ErrMethodNotImplemented, + err: plugins.ErrMethodNotImplemented, + expectedError: plugins.ErrMethodNotImplemented, + shouldPassThrough: true, }, { - err: errors.New("surprise surprise"), - expectedError: plugins.ErrPluginRequestFailureErrorBase, + err: errors.New("surprise surprise"), + expectedError: plugins.ErrPluginRequestFailureErrorBase, + shouldPassThrough: false, }, { - err: context.Canceled, - expectedError: plugins.ErrPluginRequestCanceledErrorBase, + err: context.Canceled, + expectedError: plugins.ErrPluginRequestCanceledErrorBase, + shouldPassThrough: false, + }, + { + err: plugins.ErrPluginGrpcConnectionUnavailableBase.Errorf("unavailable"), + expectedError: plugins.ErrPluginGrpcConnectionUnavailableBase.Errorf("unavailable"), + shouldPassThrough: true, + }, + { + err: plugins.ErrPluginGrpcResourceExhaustedBase.Errorf("exhausted"), + expectedError: plugins.ErrPluginGrpcResourceExhaustedBase.Errorf("exhausted"), + shouldPassThrough: true, }, } @@ -69,7 +84,11 @@ func TestQueryData(t *testing.T) { }, }) require.Error(t, err) - require.ErrorIs(t, err, tc.expectedError) + if tc.shouldPassThrough { + require.Equal(t, tc.err, err) + } else { + require.ErrorIs(t, err, tc.expectedError) + } }) } }) From 26acc66ea31aff576c3f7a724b1d32f397e105ba Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 20 Mar 2025 12:45:25 -0600 Subject: [PATCH 18/79] Dashboard - Schema V2 Stateless (ds ref independent) queries (#101661) - Rename `initializeMapping` into `initializeElementMapping` - Create new `initializeDSReferencesMapping` to track queries without explicit datasources - Fix panel datasource detection to use default datasource when none is specified - Improve datasource resolution by searching for best match by query kind - Add unit test --------- Co-authored-by: alexandra vargas Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com> Co-authored-by: Ivan Ortega Alba --- .../dashboard-scene/scene/DashboardScene.tsx | 3 +- .../DashboardSceneSerializer.test.ts | 161 +++++++++++++- .../serialization/DashboardSceneSerializer.ts | 70 +++++- .../serialization/layoutSerializers/utils.ts | 12 +- .../transformSaveModelSchemaV2ToScene.ts | 73 ------ .../transformSceneToSaveModelSchemaV2.test.ts | 208 ++++++++++++++---- .../transformSceneToSaveModelSchemaV2.ts | 53 ++++- 7 files changed, 439 insertions(+), 141 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index e7241022596..16645157dfa 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -686,7 +686,8 @@ export class DashboardScene extends SceneObjectBase impleme saveModel?: Dashboard | DashboardV2Spec, meta?: DashboardMeta | DashboardWithAccessInfo['metadata'] ): void { - this.serializer.initializeMapping(saveModel); + this.serializer.initializeElementMapping(saveModel); + this.serializer.initializeDSReferencesMapping(saveModel); const sortedModel = sortedDeepCloneWithoutNulls(saveModel); this.serializer.initialSaveModel = sortedModel; this.serializer.metadata = meta; diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index b4d8d8306b3..a48e8da3870 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -392,7 +392,7 @@ describe('DashboardSceneSerializer', () => { ], }; - serializer.initializeMapping(saveModel); + serializer.initializeElementMapping(saveModel); const mapping = serializer.getElementPanelMapping(); expect(mapping.size).toBe(2); @@ -401,10 +401,10 @@ describe('DashboardSceneSerializer', () => { }); it('should handle empty or undefined panels in initializeMapping', () => { - serializer.initializeMapping(undefined); + serializer.initializeElementMapping(undefined); expect(serializer.getElementPanelMapping().size).toBe(0); - serializer.initializeMapping({ + serializer.initializeElementMapping({ title: 'hello', uid: 'my-uid', schemaVersion: 30, @@ -424,7 +424,7 @@ describe('DashboardSceneSerializer', () => { ], }; - serializer.initializeMapping(saveModel); + serializer.initializeElementMapping(saveModel); expect(serializer.getPanelIdForElement('panel-1')).toBe(1); expect(serializer.getPanelIdForElement('panel-2')).toBe(2); @@ -441,7 +441,7 @@ describe('DashboardSceneSerializer', () => { { id: 2, title: 'Panel 2', type: 'text' }, ], }; - serializer.initializeMapping(saveModel); + serializer.initializeElementMapping(saveModel); expect(serializer.getElementIdForPanel(1)).toBe('panel-1'); expect(serializer.getElementIdForPanel(2)).toBe('panel-2'); @@ -923,7 +923,7 @@ describe('DashboardSceneSerializer', () => { }); it('should initialize panel mapping correctly', () => { - serializer.initializeMapping(saveModel); + serializer.initializeElementMapping(saveModel); const mapping = serializer.getElementPanelMapping(); expect(mapping.size).toBe(2); @@ -932,15 +932,15 @@ describe('DashboardSceneSerializer', () => { }); it('should handle empty or undefined elements in initializeMapping', () => { - serializer.initializeMapping({} as DashboardV2Spec); + serializer.initializeElementMapping({} as DashboardV2Spec); expect(serializer.getElementPanelMapping().size).toBe(0); - serializer.initializeMapping({ elements: {} } as DashboardV2Spec); + serializer.initializeElementMapping({ elements: {} } as DashboardV2Spec); expect(serializer.getElementPanelMapping().size).toBe(0); }); it('should get panel id for element correctly', () => { - serializer.initializeMapping(saveModel); + serializer.initializeElementMapping(saveModel); expect(serializer.getPanelIdForElement('element-panel-a')).toBe(1); expect(serializer.getPanelIdForElement('element-panel-b')).toBe(2); @@ -948,7 +948,7 @@ describe('DashboardSceneSerializer', () => { }); it('should get element id for panel correctly', () => { - serializer.initializeMapping(saveModel); + serializer.initializeElementMapping(saveModel); expect(serializer.getElementIdForPanel(1)).toBe('element-panel-a'); expect(serializer.getElementIdForPanel(2)).toBe('element-panel-b'); @@ -958,6 +958,147 @@ describe('DashboardSceneSerializer', () => { }); }); + describe('Datasource References Mapping', () => { + describe('V2DashboardSerializer', () => { + let serializer: V2DashboardSerializer; + + beforeEach(() => { + serializer = new V2DashboardSerializer(); + }); + + it('should initialize datasource references mapping correctly for panels with undefined datasources', () => { + const saveModel: DashboardV2Spec = { + ...defaultDashboardV2Spec(), + title: 'Test Dashboard', + elements: { + 'panel-1': { + kind: 'Panel', + spec: { + id: 1, + title: 'Panel 1', + description: '', + links: [], + vizConfig: { + kind: 'timeseries', + spec: { + pluginVersion: '1.0.0', + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }, + }, + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + // No datasource defined + query: { kind: 'sql', spec: {} }, + }, + }, + { + kind: 'PanelQuery', + spec: { + refId: 'B', + hidden: false, + datasource: { uid: 'datasource-1', type: 'prometheus' }, + query: { kind: 'prometheus', spec: {} }, + }, + }, + ], + queryOptions: {}, + transformations: [], + }, + }, + }, + }, + 'panel-2': { + kind: 'Panel', + spec: { + id: 2, + title: 'Panel 2', + description: '', + links: [], + vizConfig: { + kind: 'timeseries', + spec: { + pluginVersion: '1.0.0', + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }, + }, + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'C', + hidden: false, + // No datasource defined + query: { kind: 'sql', spec: {} }, + }, + }, + ], + queryOptions: {}, + transformations: [], + }, + }, + }, + }, + }, + }; + + serializer.initializeElementMapping(saveModel); + serializer.initializeDSReferencesMapping(saveModel); + + const dsReferencesMap = serializer.getDSReferencesMapping(); + + // Panel 1 should have refId A in the map (no datasource) + expect(dsReferencesMap.panels.has('panel-1')).toBe(true); + expect(dsReferencesMap.panels.get('panel-1')?.has('A')).toBe(true); + expect(dsReferencesMap.panels.get('panel-1')?.has('B')).toBe(false); // Has datasource defined + + // Panel 2 should have refId C in the map + expect(dsReferencesMap.panels.has('panel-2')).toBe(true); + expect(dsReferencesMap.panels.get('panel-2')?.has('C')).toBe(true); + }); + + it('should handle empty or undefined elements in initializeDSReferencesMapping', () => { + serializer.initializeDSReferencesMapping(undefined); + expect(serializer.getDSReferencesMapping().panels.size).toBe(0); + + serializer.initializeDSReferencesMapping({} as DashboardV2Spec); + expect(serializer.getDSReferencesMapping().panels.size).toBe(0); + + serializer.initializeDSReferencesMapping({ elements: {} } as DashboardV2Spec); + expect(serializer.getDSReferencesMapping().panels.size).toBe(0); + }); + }); + + describe('V1DashboardSerializer', () => { + let serializer: V1DashboardSerializer; + + beforeEach(() => { + serializer = new V1DashboardSerializer(); + }); + + it('should return empty mapping object for V1 serializer', () => { + serializer.initializeDSReferencesMapping(undefined); + expect(serializer.getDSReferencesMapping()).toEqual({ + panels: expect.any(Map), + variables: expect.any(Set), + annotations: expect.any(Set), + }); + expect(serializer.getDSReferencesMapping().panels.size).toBe(0); + }); + }); + }); + describe('onSaveComplete', () => { it('should set the initialSaveModel correctly', () => { const serializer = new V2DashboardSerializer(); diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index 98c182adc49..a5c3ec6e4d4 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -30,7 +30,8 @@ export interface DashboardSceneSerializerLike { */ initialSaveModel?: I; metadata?: M; - initializeMapping(saveModel: T | undefined): void; + initializeElementMapping(saveModel: T | undefined): void; + initializeDSReferencesMapping(saveModel: T | undefined): void; getSaveModel: (s: DashboardScene) => T; getSaveAsModel: (s: DashboardScene, options: SaveDashboardAsOptions) => T; getDashboardChangesFromScene: ( @@ -47,6 +48,7 @@ export interface DashboardSceneSerializerLike { getPanelIdForElement: (elementId: string) => number | undefined; getElementIdForPanel: (panelId: number) => string | undefined; getElementPanelMapping: () => Map; + getDSReferencesMapping: () => DSReferencesMapping; } interface DashboardTrackingInfo { @@ -58,12 +60,23 @@ interface DashboardTrackingInfo { settings_livenow?: boolean; } +interface DSReferencesMapping { + panels: Map>; + variables: Set; + annotations: Set; +} + export class V1DashboardSerializer implements DashboardSceneSerializerLike { initialSaveModel?: Dashboard; metadata?: DashboardMeta; protected elementPanelMap = new Map(); + protected defaultDsReferencesMap = { + panels: new Map>(), // refIds as keys + variables: new Set(), // variable names as keys + annotations: new Set(), // annotation names as keys + }; - initializeMapping(saveModel: Dashboard | undefined) { + initializeElementMapping(saveModel: Dashboard | undefined) { this.elementPanelMap.clear(); if (!saveModel || !saveModel.panels) { @@ -81,6 +94,15 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike['metadata']; protected elementPanelMap = new Map(); + // map of elementId that will contain all the queries, variables and annotations that dont have a ds defined + protected defaultDsReferencesMap = { + panels: new Map>(), // refIds as keys + variables: new Set(), // variable names as keys + annotations: new Set(), // annotation names as keys + }; getElementPanelMapping() { return this.elementPanelMap; } - initializeMapping(saveModel: DashboardV2Spec | undefined) { + initializeElementMapping(saveModel: DashboardV2Spec | undefined) { this.elementPanelMap.clear(); if (!saveModel || !saveModel.elements) { @@ -206,6 +234,42 @@ export class V2DashboardSerializer }); } + initializeDSReferencesMapping(saveModel: DashboardV2Spec | undefined) { + // initialize the object + this.defaultDsReferencesMap = { + panels: new Map>(), + variables: new Set(), + annotations: new Set(), + }; + + // get all the element keys + const elementKeys = Object.keys(saveModel?.elements || {}); + elementKeys.forEach((key) => { + const elementPanel = saveModel?.elements[key]; + if (elementPanel?.kind === 'Panel') { + // check if the elementPanel.spec.datasource is defined + const panelQueries = elementPanel.spec.data.spec.queries; + + for (const query of panelQueries) { + if (!query.spec.datasource) { + const elementId = this.getElementIdForPanel(elementPanel.spec.id); + if (!this.defaultDsReferencesMap.panels.has(elementId)) { + this.defaultDsReferencesMap.panels.set(elementId, new Set()); + } + + const panelDsqueries = this.defaultDsReferencesMap.panels.get(elementId)!; + + panelDsqueries.add(query.spec.refId); + } + } + } + }); + } + + getDSReferencesMapping() { + return this.defaultDsReferencesMap; + } + getPanelIdForElement(elementId: string) { return this.elementPanelMap.get(elementId); } diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 637ce85c7a3..66bcaed53f7 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -182,13 +182,21 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { panel.spec.data.spec.queries.forEach((query) => { if (!datasource) { - datasource = query.spec.datasource; + if (!query.spec.datasource?.uid) { + const defaultDatasource = config.bootData.settings.defaultDatasource; + const dsList = config.bootData.settings.datasources; + // this is look up by type + const bestGuess = Object.values(dsList).find((ds) => ds.meta.id === query.spec.query.kind); + datasource = bestGuess ? { uid: bestGuess.uid, type: bestGuess.meta.id } : dsList[defaultDatasource]; + } else { + datasource = query.spec.datasource; + } } else if (datasource.uid !== query.spec.datasource?.uid || datasource.type !== query.spec.datasource?.type) { isMixedDatasource = true; } }); - return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined; + return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : datasource; } function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 1230f03b547..ceb9ebd7eeb 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -11,10 +11,6 @@ import { IntervalVariable, QueryVariable, SceneDataLayerControls, - SceneDataProvider, - SceneDataQuery, - SceneDataTransformer, - SceneQueryRunner, SceneRefreshPicker, SceneTimePicker, SceneTimeRange, @@ -23,7 +19,6 @@ import { TextBoxVariable, VariableValueSelectors, } from '@grafana/scenes'; -import { DataSourceRef } from '@grafana/schema/dist/esm/index.gen'; import { AdhocVariableKind, ConstantVariableKind, @@ -42,7 +37,6 @@ import { IntervalVariableKind, LibraryPanelKind, PanelKind, - PanelQueryKind, QueryVariableKind, TextVariableKind, } from '@grafana/schema/src/schema/dashboard/v2alpha0'; @@ -55,14 +49,12 @@ import { DeprecatedInternalId, } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; -import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { DashboardMeta } from 'app/types'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; -import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour'; import { registerDashboardMacro } from '../scene/DashboardMacro'; import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior'; import { DashboardScene } from '../scene/DashboardScene'; @@ -218,71 +210,6 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo { - if (!datasource) { - datasource = query.spec.datasource; - } else if (datasource.uid !== query.spec.datasource?.uid || datasource.type !== query.spec.datasource?.type) { - isMixedDatasource = true; - } - }); - - return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : datasource; -} - -function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { - return { - refId: query.spec.refId, - datasource: query.spec.datasource, - hide: query.spec.hidden, - ...query.spec.query.spec, - }; -} - -export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider | undefined { - const panel = panelKind.spec; - const targets = panel.data?.spec.queries ?? []; - // Skip setting query runner for panels without queries - if (!targets?.length) { - return undefined; - } - - // Skip setting query runner for panel plugins with skipDataQuery - if (config.panels[panel.vizConfig.kind]?.skipDataQuery) { - return undefined; - } - - let dataProvider: SceneDataProvider | undefined = undefined; - const datasource = getPanelDataSource(panelKind); - - dataProvider = new SceneQueryRunner({ - datasource, - queries: targets.map(panelQueryKindToSceneQuery), - maxDataPoints: panel.data.spec.queryOptions.maxDataPoints ?? undefined, - maxDataPointsFromWidth: true, - cacheTimeout: panel.data.spec.queryOptions.cacheTimeout, - queryCachingTTL: panel.data.spec.queryOptions.queryCachingTTL, - minInterval: panel.data.spec.queryOptions.interval ?? undefined, - dataLayerFilter: { - panelId: panel.id, - }, - $behaviors: [new DashboardDatasourceBehaviour({})], - }); - - // Wrap inner data provider in a data transformer - return new SceneDataTransformer({ - $data: dataProvider, - transformations: panel.data.spec.transformations.map((transformation) => transformation.spec), - }); -} - function getVariables(dashboard: DashboardV2Spec, isSnapshot: boolean): SceneVariableSet | undefined { let variables: SceneVariableSet | undefined; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index db16a475798..ff1df0ded25 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -17,6 +17,8 @@ import { SceneVariableSet, TextBoxVariable, VizPanel, + SceneDataQuery, + SceneQueryRunner, } from '@grafana/scenes'; import { DashboardCursorSync as DashboardCursorSyncV1, @@ -48,7 +50,38 @@ import { TabItem } from '../scene/layout-tabs/TabItem'; import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; -import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; +import { getPersistedDSForQuery, transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; + +// Mock dependencies +jest.mock('../utils/dashboardSceneGraph', () => { + const original = jest.requireActual('../utils/dashboardSceneGraph'); + return { + ...original, + dashboardSceneGraph: { + ...original.dashboardSceneGraph, + getElementIdentifierForVizPanel: jest.fn().mockImplementation((panel) => { + // Return the panel key if it exists, otherwise use panel-1 as default + return panel?.state?.key || 'panel-1'; + }), + }, + }; +}); + +jest.mock('../utils/utils', () => { + const original = jest.requireActual('../utils/utils'); + return { + ...original, + getDashboardSceneFor: jest.fn().mockImplementation(() => ({ + serializer: { + getDSReferencesMapping: jest.fn().mockReturnValue({ + panels: new Map([['panel-1', new Set(['A'])]]), + variables: new Set(), + annotations: new Set(), + }), + }, + })), + }; +}); function setupDashboardScene(state: Partial): DashboardScene { return new DashboardScene(state); @@ -100,7 +133,7 @@ describe('transformSceneToSaveModelSchemaV2', () => { // The intention is to have a complete dashboard scene // with all the possible properties set dashboardScene = setupDashboardScene({ - $data: new DashboardDataLayerSet({ annotationLayers }), + $data: new DashboardDataLayerSet({ annotationLayers: createAnnotationLayers() }), id: 1, title: 'Test Dashboard', description: 'Test Description', @@ -377,6 +410,84 @@ describe('transformSceneToSaveModelSchemaV2', () => { // check annotation layer 3 with no datasource has the default datasource defined as type expect(result.annotations?.[2].spec.datasource?.type).toBe('loki'); }); + + describe('getPersistedDSForQuery', () => { + it('should respect datasource reference mapping when determining query datasource', () => { + // Setup test data + const queryWithoutDS: SceneDataQuery = { + refId: 'A', + // No datasource defined originally + }; + const queryWithDS: SceneDataQuery = { + refId: 'B', + datasource: { uid: 'prometheus', type: 'prometheus' }, + }; + + // Mock query runner with runtime-resolved datasource + const queryRunner = new SceneQueryRunner({ + queries: [queryWithoutDS, queryWithDS], + datasource: { uid: 'default-ds', type: 'default' }, + }); + + // Get a reference to the DS references mapping + const dsReferencesMap = new Set(['A']); + + // Test the query without DS originally - should return undefined + const resultA = getPersistedDSForQuery(queryWithoutDS, queryRunner, dsReferencesMap); + expect(resultA).toBeUndefined(); + + // Test the query with DS originally - should return the original datasource + const resultB = getPersistedDSForQuery(queryWithDS, queryRunner, dsReferencesMap); + expect(resultB).toEqual({ uid: 'prometheus', type: 'prometheus' }); + + // Test a query with no DS originally but not in the mapping - should get the runner's datasource + const queryNotInMapping: SceneDataQuery = { + refId: 'C', + // No datasource, but not in mapping + }; + const resultC = getPersistedDSForQuery(queryNotInMapping, queryRunner, dsReferencesMap); + expect(resultC).toEqual({ uid: 'default-ds', type: 'default' }); + }); + }); + + describe('getDatasourceForQueries', () => { + it('should respect datasource reference mapping when determining which queries should have datasources saved', () => { + // Setup test data + const queryWithoutDS: SceneDataQuery = { + refId: 'A', + // No datasource defined originally + }; + const queryWithDS: SceneDataQuery = { + refId: 'B', + datasource: { uid: 'prometheus', type: 'prometheus' }, + }; + + // Mock query runner with runtime-resolved datasource + const queryRunner = new SceneQueryRunner({ + queries: [queryWithoutDS, queryWithDS], + datasource: { uid: 'default-ds', type: 'default' }, + }); + + // Get a reference to the DS references mapping + const dsReferencesMap = new Set(['A']); + + // Test the query without DS originally - should return undefined + const resultA = getPersistedDSForQuery(queryWithoutDS, queryRunner, dsReferencesMap); + expect(resultA).toBeUndefined(); + + // Test the query with DS originally - should return the original datasource + const resultB = getPersistedDSForQuery(queryWithDS, queryRunner, dsReferencesMap); + expect(resultB).toEqual({ uid: 'prometheus', type: 'prometheus' }); + + // Test a query with no DS originally but not in the mapping - should get the runner's datasource + const queryNotInMapping: SceneDataQuery = { + refId: 'C', + // No datasource, but not in mapping + }; + const resultC = getPersistedDSForQuery(queryNotInMapping, queryRunner, dsReferencesMap); + expect(resultC).toEqual({ uid: 'default-ds', type: 'default' }); + }); + }); }); function getMinimalSceneState(body: DashboardLayoutManager): Partial { @@ -571,49 +682,50 @@ describe('dynamic layouts', () => { }); }); -const annotationLayer1 = new DashboardAnnotationsDataLayer({ - key: 'layer1', - query: { - datasource: { - type: 'grafana', - uid: '-- Grafana --', - }, - name: 'query1', - enable: true, - iconColor: 'red', - }, - name: 'layer1', - isEnabled: true, - isHidden: false, -}); - -const annotationLayer2 = new DashboardAnnotationsDataLayer({ - key: 'layer2', - query: { - datasource: { - type: 'prometheus', - uid: 'abcdef', - }, - name: 'query2', - enable: true, - iconColor: 'blue', - }, - name: 'layer2', - isEnabled: true, - isHidden: true, -}); - -// this could happen if a dahboard was created from code and the datasource was not defined -const annotationLayer3NoDsDefined = new DashboardAnnotationsDataLayer({ - key: 'layer3', - query: { - name: 'query3', - enable: true, - iconColor: 'green', - }, - name: 'layer3', - isEnabled: true, - isHidden: true, -}); - -const annotationLayers = [annotationLayer1, annotationLayer2, annotationLayer3NoDsDefined]; +// Instead of reusing annotation layer objects, create a factory function to generate new ones each time +function createAnnotationLayers() { + return [ + new DashboardAnnotationsDataLayer({ + key: 'layer1', + query: { + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + name: 'query1', + enable: true, + iconColor: 'red', + }, + name: 'layer1', + isEnabled: true, + isHidden: false, + }), + new DashboardAnnotationsDataLayer({ + key: 'layer2', + query: { + datasource: { + type: 'prometheus', + uid: 'abcdef', + }, + name: 'query2', + enable: true, + iconColor: 'blue', + }, + name: 'layer2', + isEnabled: true, + isHidden: true, + }), + // this could happen if a dahboard was created from code and the datasource was not defined + new DashboardAnnotationsDataLayer({ + key: 'layer3', + query: { + name: 'query3', + enable: true, + iconColor: 'green', + }, + name: 'layer3', + isEnabled: true, + isHidden: true, + }), + ]; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 605848c5b53..b88dfa68719 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -7,6 +7,7 @@ import { dataLayers, SceneDataQuery, SceneDataTransformer, + SceneQueryRunner, SceneVariableSet, VizPanel, } from '@grafana/scenes'; @@ -45,7 +46,13 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; -import { getLibraryPanelBehavior, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; +import { + getDashboardSceneFor, + getLibraryPanelBehavior, + getPanelIdForVizPanel, + getQueryRunnerFor, + isLibraryPanel, +} from '../utils/utils'; import { getLayout } from './layoutSerializers/utils'; import { sceneVariablesSetToSchemaV2Variables } from './sceneVariablesSetToVariables'; @@ -239,16 +246,16 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] { const queries: PanelQueryKind[] = []; const queryRunner = getQueryRunnerFor(vizPanel); const vizPanelQueries = queryRunner?.state.queries; - const datasource = queryRunner?.state.datasource ?? getDefaultDataSourceRef(); - + const autoAssignedPanelDSRef = getAutoAssignedPanelDSRef(vizPanel); if (vizPanelQueries) { vizPanelQueries.forEach((query) => { + const queryDatasource = getPersistedDSForQuery(query, queryRunner, autoAssignedPanelDSRef); const dataQuery: DataQueryKind = { kind: getDataQueryKind(query), spec: omit(query, 'datasource', 'refId', 'hide'), }; const querySpec: PanelQuerySpec = { - datasource: query.datasource ?? datasource, + datasource: queryDatasource, query: dataQuery, refId: query.refId, hidden: Boolean(query.hide), @@ -584,3 +591,41 @@ function validateRowsLayout(layout: unknown) { throw new Error('Layout spec items is not an array'); } } + +/** + * Get a collection of panel queries refIds + * the refIds are the ones which did not have a datasource set + * @returns a set of panel queries refIds + */ +function getAutoAssignedPanelDSRef(vizPanel: VizPanel) { + const elementKey = dashboardSceneGraph.getElementIdentifierForVizPanel(vizPanel); + const scene = getDashboardSceneFor(vizPanel); + const elementMapReferences = scene.serializer.getDSReferencesMapping(); + + const panelQueries = elementMapReferences.panels.get(elementKey); + return panelQueries; +} + +/** + * Get the persisted datasource for a query + * When a query is created it could not have a datasource set + * we want to respect that and not overwrite it with the auto assigned datasources + * resolved in runtime + * @param query + * @param queryRunner + * @param autoAssignedPanelDsRef + * @returns + */ +export function getPersistedDSForQuery( + query: SceneDataQuery, + queryRunner: SceneQueryRunner, + autoAssignedPanelDsRef: Set | undefined +) { + // if the query has a refId and it is in the panelDsReferences then it did NOT have a datasource + const hasMatchingRefId = autoAssignedPanelDsRef?.has(query.refId); + if (hasMatchingRefId) { + return undefined; + } + + return query.datasource || queryRunner?.state?.datasource; +} From b3a529de48f039ecf7ee8f21e81a67bc876887c8 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 20 Mar 2025 22:50:04 +0300 Subject: [PATCH 19/79] K8s/Dashboard: Use upsert to create or update dashboards (#102536) --- .../dashboards/service/dashboard_service.go | 37 ++++--------------- .../service/dashboard_service_test.go | 10 ++--- 2 files changed, 11 insertions(+), 36 deletions(-) diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index a8516b0d594..9e26875ff4c 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -11,8 +11,6 @@ import ( "time" "github.com/google/uuid" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" - "github.com/grafana/grafana/pkg/util/retryer" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" "golang.org/x/exp/maps" @@ -34,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/slugify" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/client" @@ -56,6 +55,7 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/search" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/retryer" ) var ( @@ -1576,12 +1576,13 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C meta.SetManagerProperties(m) meta.SetSourceProperties(s) - out, err := dr.createOrUpdateDash(ctx, obj, cmd.OrgID) + // Update will create if not exists (upsert!) + out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID) if err != nil { return nil, err } - return out, nil + return dr.UnstructuredToLegacyDashboard(ctx, out, cmd.OrgID) } func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd *dashboards.SaveDashboardCommand, orgID int64) (*dashboards.Dashboard, error) { @@ -1592,35 +1593,13 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd dashboard.SetPluginIDMeta(obj, cmd.PluginID) - out, err := dr.createOrUpdateDash(ctx, obj, orgID) + // Update will create if not exists (upsert!) + out, err := dr.k8sclient.Update(ctx, obj, orgID) if err != nil { return nil, err } - return out, nil -} - -func (dr *DashboardServiceImpl) createOrUpdateDash(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) { - var out *unstructured.Unstructured - current, err := dr.k8sclient.Get(ctx, obj.GetName(), orgID, v1.GetOptions{}) - if current == nil || err != nil { - out, err = dr.k8sclient.Create(ctx, obj, orgID) - if err != nil { - return nil, err - } - } else { - out, err = dr.k8sclient.Update(ctx, obj, orgID) - if err != nil { - return nil, err - } - } - - finalDash, err := dr.UnstructuredToLegacyDashboard(ctx, out, orgID) - if err != nil { - return nil, err - } - - return finalDash, nil + return dr.UnstructuredToLegacyDashboard(ctx, out, orgID) } func (dr *DashboardServiceImpl) deleteAllDashboardThroughK8s(ctx context.Context, orgID int64) error { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 0f68c97d2ec..78c38656329 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -1360,9 +1360,8 @@ func TestSaveProvisionedDashboard(t *testing.T) { t.Run("Should use Kubernetes create if feature flags are enabled", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) - k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") dashboard, err := service.SaveProvisionedDashboard(ctx, query, &dashboards.DashboardProvisioning{}) @@ -1422,10 +1421,9 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) dashboard, err := service.SaveDashboard(ctx, query, false) require.NoError(t, err) @@ -1434,7 +1432,6 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) @@ -1446,9 +1443,8 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should return an error if uid is invalid", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") - k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) query.Dashboard.UID = "invalid/uid" _, err := service.SaveDashboard(ctx, query, false) From 92cc10f98312a2c5ff15a6e86c981b1f3eeb93d0 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 20 Mar 2025 21:14:02 +0000 Subject: [PATCH 20/79] Logs: Prevent automatic scrolling on refresh after changing scroll position (#102463) --- .../logs/components/InfiniteScroll.test.tsx | 29 +++++++++++------ .../logs/components/InfiniteScroll.tsx | 3 +- public/app/plugins/panel/logs/LogsPanel.tsx | 31 +++++++++++++++++-- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx index c6b92afaef5..9b9792c2849 100644 --- a/public/app/features/logs/components/InfiniteScroll.test.tsx +++ b/public/app/features/logs/components/InfiniteScroll.test.tsx @@ -62,11 +62,15 @@ function setup( ) { const { element, events } = getMockElement(startPosition); - function scrollTo(position: number) { + function scrollTo(position: number, timeStamp?: number) { element.scrollTop = position; act(() => { - events['scroll'](new Event('scroll')); + const event = new Event('scroll'); + if (timeStamp) { + jest.spyOn(event, 'timeStamp', 'get').mockReturnValue(timeStamp); + } + events['scroll'](event); }); // When scrolling top, we wait for the user to reach the top, and then for a new scrolling event @@ -153,7 +157,8 @@ describe('InfiniteScroll', () => { expect(await screen.findByTestId('contents')).toBeInTheDocument(); - scrollTo(endPosition); + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); expect(loadMoreMock).toHaveBeenCalled(); expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); @@ -177,7 +182,8 @@ describe('InfiniteScroll', () => { expect(await screen.findByTestId('contents')).toBeInTheDocument(); - wheel(deltaY); + wheel(deltaY, 1); + wheel(deltaY, 600); expect(loadMoreMock).toHaveBeenCalled(); expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); @@ -192,7 +198,8 @@ describe('InfiniteScroll', () => { element.clientHeight = 40; element.scrollHeight = element.clientHeight; - scrollTo(40); + scrollTo(39, 1); + scrollTo(40, 600); expect(loadMoreMock).not.toHaveBeenCalled(); expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); @@ -207,7 +214,8 @@ describe('InfiniteScroll', () => { expect(await screen.findByTestId('contents')).toBeInTheDocument(); - scrollTo(endPosition); + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); expect(loadMoreMock).toHaveBeenCalledWith({ from: rows[rows.length - 1].timeEpochMs, @@ -224,7 +232,8 @@ describe('InfiniteScroll', () => { expect(await screen.findByTestId('contents')).toBeInTheDocument(); - scrollTo(endPosition); + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); expect(loadMoreMock).toHaveBeenCalledWith({ from: absoluteRange.from, @@ -246,7 +255,8 @@ describe('InfiniteScroll', () => { expect(await screen.findByTestId('contents')).toBeInTheDocument(); - scrollTo(endPosition); + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); expect(loadMoreMock).not.toHaveBeenCalled(); expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); @@ -269,7 +279,8 @@ describe('InfiniteScroll', () => { expect(await screen.findByTestId('contents')).toBeInTheDocument(); - scrollTo(endPosition); + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); expect(loadMoreMock).not.toHaveBeenCalled(); expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx index 36d748d2db1..5ebe5516332 100644 --- a/public/app/features/logs/components/InfiniteScroll.tsx +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -98,6 +98,7 @@ export const InfiniteScroll = ({ } else if (scrollDirection === ScrollDirection.Bottom) { scrollBottom(); } + lastEvent.current = null; } function scrollTop() { @@ -245,7 +246,7 @@ export function shouldLoadMore( return ScrollDirection.NoScroll; } - if (lastEvent && shouldIgnoreChainOfEvents(event, lastEvent, countRef)) { + if (!lastEvent || shouldIgnoreChainOfEvents(event, lastEvent, countRef)) { return ScrollDirection.NoScroll; } diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index a335e830aca..088f4fd2b14 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -143,7 +143,7 @@ export const LogsPanel = ({ const [panelData, setPanelData] = useState(data); const dataSourcesMap = useDatasourcesFromTargets(panelData.request?.targets); // Prevents the scroll position to change when new data from infinite scrolling is received - const keepScrollPositionRef = useRef(false); + const keepScrollPositionRef = useRef(null); let closeCallback = useRef<() => void>(); const { eventBus, onAddAdHocFilter } = usePanelContext(); @@ -290,7 +290,8 @@ export const LogsPanel = ({ useLayoutEffect(() => { if (!logsContainerRef.current || !scrollElement || keepScrollPositionRef.current) { - keepScrollPositionRef.current = false; + keepScrollPositionRef.current = + keepScrollPositionRef.current === 'infinite-scroll' ? null : keepScrollPositionRef.current; return; } /** @@ -370,6 +371,30 @@ export const LogsPanel = ({ } }, [options.displayedFields]); + // Respect the scroll position when refreshing the panel + useEffect(() => { + function handleScroll() { + if (!scrollElement) { + return; + } + // Signal to keep the user scroll position + keepScrollPositionRef.current = 'user'; + const atTheBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight === 0; + // Except when the user resets the scroll to the original position depending on the sort direction + if (scrollElement.scrollTop === 0 && !isAscending) { + keepScrollPositionRef.current = null; + } else if (atTheBottom && isAscending) { + keepScrollPositionRef.current = null; + } + } + scrollElement?.addEventListener('scroll', handleScroll); + scrollElement?.addEventListener('wheel', handleScroll); + return () => { + scrollElement?.removeEventListener('scroll', handleScroll); + scrollElement?.removeEventListener('wheel', handleScroll); + }; + }, [isAscending, scrollElement]); + const loadMoreLogs = useCallback( async (scrollRange: AbsoluteTimeRange) => { if (!data.request || !config.featureToggles.logsInfiniteScrolling || loadingRef.current) { @@ -391,7 +416,7 @@ export const LogsPanel = ({ loadingRef.current = false; } - keepScrollPositionRef.current = true; + keepScrollPositionRef.current = 'infinite-scroll'; setPanelData({ ...panelData, series: newSeries, From c33a53a47a8a2bb582cd42ed954d892369e2ea13 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Thu, 20 Mar 2025 15:38:32 -0600 Subject: [PATCH 21/79] K8s: Remove restore functionality; can be done with list (#102560) --- .../feature-toggles/index.md | 1 - .../src/types/featureToggles.gen.ts | 4 - pkg/registry/apis/dashboard/authorizer.go | 6 +- pkg/registry/apis/dashboard/latest.go | 103 -- pkg/registry/apis/dashboard/latest_test.go | 99 -- pkg/registry/apis/dashboard/legacy/client.go | 5 - pkg/registry/apis/dashboard/register.go | 5 - pkg/registry/apis/dashboard/restore.go | 122 -- pkg/registry/apis/dashboard/restore_test.go | 126 -- pkg/registry/apis/dashboard/search.go | 2 +- pkg/registry/apis/dashboard/search_test.go | 3 - pkg/services/dashboards/models.go | 2 - .../dashboards/service/dashboard_service.go | 10 +- pkg/services/featuremgmt/registry.go | 6 - pkg/services/featuremgmt/toggles-gitlog.csv | 1 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/services/featuremgmt/toggles_gen.json | 12 - pkg/storage/unified/resource/resource.pb.go | 1355 ++++++++--------- pkg/storage/unified/resource/resource.proto | 23 - .../unified/resource/resource_grpc.pb.go | 275 ++-- pkg/storage/unified/resource/server.go | 130 +- pkg/storage/unified/resource/server_test.go | 75 - pkg/storage/unified/sql/backend.go | 81 - pkg/storage/unified/sql/backend_test.go | 82 - .../sql/data/resource_history_read.sql | 6 +- .../sql/data/resource_history_update_uid.sql | 8 - pkg/storage/unified/sql/queries.go | 14 - pkg/storage/unified/sql/queries_test.go | 20 - ...tory_update_uid-modify uids in history.sql | 8 - ...tory_update_uid-modify uids in history.sql | 8 - ...tory_update_uid-modify uids in history.sql | 8 - 32 files changed, 732 insertions(+), 1873 deletions(-) delete mode 100644 pkg/registry/apis/dashboard/latest.go delete mode 100644 pkg/registry/apis/dashboard/latest_test.go delete mode 100644 pkg/registry/apis/dashboard/restore.go delete mode 100644 pkg/registry/apis/dashboard/restore_test.go delete mode 100644 pkg/storage/unified/sql/data/resource_history_update_uid.sql delete mode 100755 pkg/storage/unified/sql/testdata/mysql--resource_history_update_uid-modify uids in history.sql delete mode 100755 pkg/storage/unified/sql/testdata/postgres--resource_history_update_uid-modify uids in history.sql delete mode 100755 pkg/storage/unified/sql/testdata/sqlite--resource_history_update_uid-modify uids in history.sql diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 786bdcd6bde..bf962a6c350 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -164,7 +164,6 @@ Experimental features might be changed or removed without prior notice. | `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) | | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | -| `kubernetesRestore` | Allow restoring objects in k8s | | `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | | `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 43e3ccea8ef..99479a1b5ef 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -390,10 +390,6 @@ export interface FeatureToggles { */ kubernetesDashboards?: boolean; /** - * Allow restoring objects in k8s - */ - kubernetesRestore?: boolean; - /** * Route the folder and dashboard service requests to k8s */ kubernetesClientDashboardsFolders?: boolean; diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go index 0b51cd32c68..a6e316b1584 100644 --- a/pkg/registry/apis/dashboard/authorizer.go +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -41,11 +41,9 @@ func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) a } // expensive path to lookup permissions for a single dashboard - // must include deleted to allow for restores dto, err := dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{ - UID: attr.GetName(), - OrgID: info.OrgID, - IncludeDeleted: true, + UID: attr.GetName(), + OrgID: info.OrgID, }) if err != nil { return authorizer.DecisionDeny, "error loading dashboard", err diff --git a/pkg/registry/apis/dashboard/latest.go b/pkg/registry/apis/dashboard/latest.go deleted file mode 100644 index 0944bcdf537..00000000000 --- a/pkg/registry/apis/dashboard/latest.go +++ /dev/null @@ -1,103 +0,0 @@ -package dashboard - -import ( - "context" - "fmt" - "net/http" - "strconv" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/registry/rest" - "k8s.io/apiserver/pkg/storage" - - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/storage/unified/resource" -) - -// LatestConnector will return the latest version of the resource - even if it is deleted -type LatestConnector interface { - rest.Storage - rest.Connecter - rest.StorageMetadata -} - -func NewLatestConnector(unified resource.ResourceClient, gr schema.GroupResource) LatestConnector { - return &latestREST{ - unified: unified, - gr: gr, - } -} - -type latestREST struct { - unified resource.ResourceClient - gr schema.GroupResource -} - -func (l *latestREST) New() runtime.Object { - return &metav1.PartialObjectMetadataList{} -} - -func (l *latestREST) Destroy() { -} - -func (l *latestREST) ConnectMethods() []string { - return []string{"GET"} -} - -func (l *latestREST) ProducesMIMETypes(verb string) []string { - return nil -} - -func (l *latestREST) ProducesObject(verb string) interface{} { - return &metav1.PartialObjectMetadataList{} -} - -func (l *latestREST) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" -} - -func (l *latestREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - key := &resource.ResourceKey{ - Namespace: info.Value, - Group: l.gr.Group, - Resource: l.gr.Resource, - Name: uid, - } - - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - rsp, err := l.unified.Read(ctx, &resource.ReadRequest{ - Key: key, - ResourceVersion: 0, // 0 will return the latest version that was not a delete event - IncludeDeleted: true, - }) - if err != nil { - responder.Error(err) - return - } else if rsp == nil || (rsp.Error != nil && rsp.Error.Code == http.StatusNotFound) { - responder.Error(storage.NewKeyNotFoundError(uid, 0)) - return - } else if rsp.Error != nil { - responder.Error(fmt.Errorf("could not retrieve object: %s", rsp.Error.Message)) - return - } - - uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, rsp.Value) - if err != nil { - responder.Error(fmt.Errorf("could not convert object: %s", err.Error())) - return - } - - finalObj := uncastObj.(*unstructured.Unstructured) - finalObj.SetResourceVersion(strconv.FormatInt(rsp.ResourceVersion, 10)) - - responder.Object(http.StatusOK, finalObj) - }), nil -} diff --git a/pkg/registry/apis/dashboard/latest_test.go b/pkg/registry/apis/dashboard/latest_test.go deleted file mode 100644 index cdf54985a70..00000000000 --- a/pkg/registry/apis/dashboard/latest_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package dashboard - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "reflect" - "strconv" - "testing" - - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/endpoints/request" -) - -func TestLatest(t *testing.T) { - gr := schema.GroupResource{ - Group: "group", - Resource: "resource", - } - ctx := context.Background() - mockResponder := &mockResponder{} - mockClient := &mockResourceClient{} - r := &latestREST{ - unified: mockClient, - gr: gr, - } - - t.Run("no namespace in context", func(t *testing.T) { - _, err := r.Connect(ctx, "test-uid", nil, mockResponder) - require.Error(t, err) - }) - - ctx = request.WithNamespace(context.Background(), "default") - - t.Run("happy path", func(t *testing.T) { - req := httptest.NewRequest("GET", "/latest", nil) - w := httptest.NewRecorder() - - readReq := &resource.ReadRequest{ - Key: &resource.ResourceKey{ - Namespace: "default", - Group: "group", - Resource: "resource", - Name: "uid", - }, - ResourceVersion: 0, - IncludeDeleted: true, - } - - expectedObject := &metav1.PartialObjectMetadata{ - TypeMeta: metav1.TypeMeta{ - Kind: "resource", - APIVersion: "v0alpha1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "uid", - Namespace: "default", - ResourceVersion: strconv.FormatInt(123, 10), - }, - } - - val, err := json.Marshal(expectedObject) - require.NoError(t, err) - mockClient.On("Read", ctx, readReq).Return(&resource.ReadResponse{ - ResourceVersion: 123, - Value: val, - }, nil).Once() - - mockResponder.On("Object", http.StatusOK, mock.MatchedBy(func(obj interface{}) bool { - unstructuredObj, ok := obj.(*unstructured.Unstructured) - expectedMap := map[string]interface{}{ - "apiVersion": expectedObject.APIVersion, - "kind": expectedObject.Kind, - "metadata": map[string]interface{}{ - "name": expectedObject.Name, - "namespace": expectedObject.Namespace, - "resourceVersion": expectedObject.ResourceVersion, - "creationTimestamp": nil, - }, - } - return ok && reflect.DeepEqual(unstructuredObj.Object, expectedMap) - })) - - handler, err := r.Connect(ctx, "uid", nil, mockResponder) - require.NoError(t, err) - handler.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - - mockClient.AssertExpectations(t) - mockResponder.AssertExpectations(t) - }) -} diff --git a/pkg/registry/apis/dashboard/legacy/client.go b/pkg/registry/apis/dashboard/legacy/client.go index afcd1e0fddf..d85c66047c9 100644 --- a/pkg/registry/apis/dashboard/legacy/client.go +++ b/pkg/registry/apis/dashboard/legacy/client.go @@ -70,11 +70,6 @@ func (d *directResourceClient) Read(ctx context.Context, in *resource.ReadReques return d.server.Read(ctx, in) } -// Restore implements ResourceClient. -func (d *directResourceClient) Restore(ctx context.Context, in *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) { - return d.server.Restore(ctx, in) -} - // Search implements ResourceClient. func (d *directResourceClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) { return d.server.Search(ctx, in) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index d09af5df91c..101d36cd2da 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -280,11 +280,6 @@ func (b *DashboardsAPIBuilder) storageForVersion( return err } - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) { - storage[dashboards.StoragePath("restore")] = NewRestoreConnector(b.unified, gr) - storage[dashboards.StoragePath("latest")] = NewLatestConnector(b.unified, gr) - } - // Register the DTO endpoint that will consolidate all dashboard bits storage[dashboards.StoragePath("dto")], err = NewDTOConnector( storage[dashboards.StoragePath()].(rest.Getter), diff --git a/pkg/registry/apis/dashboard/restore.go b/pkg/registry/apis/dashboard/restore.go deleted file mode 100644 index 839516cbb31..00000000000 --- a/pkg/registry/apis/dashboard/restore.go +++ /dev/null @@ -1,122 +0,0 @@ -package dashboard - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/registry/rest" - "k8s.io/apiserver/pkg/storage" - - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/storage/unified/resource" -) - -type RestoreConnector interface { - rest.Storage - rest.Connecter - rest.StorageMetadata -} - -func NewRestoreConnector(unified resource.ResourceClient, gr schema.GroupResource) RestoreConnector { - return &restoreREST{ - unified: unified, - gr: gr, - } -} - -type restoreREST struct { - unified resource.ResourceClient - gr schema.GroupResource -} - -func (r *restoreREST) New() runtime.Object { - return &metav1.PartialObjectMetadataList{} -} - -func (r *restoreREST) Destroy() { -} - -func (r *restoreREST) ConnectMethods() []string { - return []string{"POST"} -} - -func (r *restoreREST) ProducesMIMETypes(verb string) []string { - return nil -} - -func (r *restoreREST) ProducesObject(verb string) interface{} { - return &metav1.PartialObjectMetadataList{} -} - -func (r *restoreREST) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" -} - -type RestoreOptions struct { - ResourceVersion int64 `json:"resourceVersion"` -} - -func (r *restoreREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - key := &resource.ResourceKey{ - Namespace: info.Value, - Group: r.gr.Group, - Resource: r.gr.Resource, - Name: uid, - } - - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, err := io.ReadAll(req.Body) - if err != nil { - responder.Error(fmt.Errorf("unable to read request body: %s", err.Error())) - return - } - reqBody := &RestoreOptions{} - err = json.Unmarshal(body, &reqBody) - if err != nil { - responder.Error(fmt.Errorf("unable to unmarshal request body: %s", err.Error())) - return - } - - if reqBody.ResourceVersion == 0 { - responder.Error(fmt.Errorf("resource version required")) - return - } - - rsp, err := r.unified.Restore(ctx, &resource.RestoreRequest{ - ResourceVersion: reqBody.ResourceVersion, - Key: key, - }) - if err != nil { - responder.Error(err) - return - } else if rsp == nil || (rsp.Error != nil && rsp.Error.Code == http.StatusNotFound) { - responder.Error(storage.NewKeyNotFoundError(uid, reqBody.ResourceVersion)) - return - } else if rsp.Error != nil { - responder.Error(fmt.Errorf("could not re-create object: %s", rsp.Error.Message)) - return - } - - obj := metav1.PartialObjectMetadata{ - ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, - ResourceVersion: strconv.FormatInt(rsp.ResourceVersion, 10), - }, - } - - responder.Object(http.StatusOK, &obj) - }), nil -} diff --git a/pkg/registry/apis/dashboard/restore_test.go b/pkg/registry/apis/dashboard/restore_test.go deleted file mode 100644 index 26edde534ef..00000000000 --- a/pkg/registry/apis/dashboard/restore_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package dashboard - -import ( - "bytes" - "context" - "fmt" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "google.golang.org/grpc" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/endpoints/request" -) - -type mockResourceClient struct { - mock.Mock - resource.ResourceClient -} - -func (m *mockResourceClient) Restore(ctx context.Context, req *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) { - args := m.Called(ctx, req) - return args.Get(0).(*resource.RestoreResponse), args.Error(1) -} - -func (m *mockResourceClient) Read(ctx context.Context, req *resource.ReadRequest, opts ...grpc.CallOption) (*resource.ReadResponse, error) { - args := m.Called(ctx, req) - return args.Get(0).(*resource.ReadResponse), args.Error(1) -} - -type mockResponder struct { - mock.Mock -} - -func (m *mockResponder) Object(statusCode int, obj runtime.Object) { - m.Called(statusCode, obj) -} - -func (m *mockResponder) Error(err error) { - m.Called(err) -} - -func TestRestore(t *testing.T) { - gr := schema.GroupResource{ - Group: "group", - Resource: "resource", - } - ctx := context.Background() - mockResponder := &mockResponder{} - mockClient := &mockResourceClient{} - r := &restoreREST{ - unified: mockClient, - gr: gr, - } - - t.Run("no namespace in context", func(t *testing.T) { - _, err := r.Connect(ctx, "test-uid", nil, mockResponder) - assert.Error(t, err) - }) - - ctx = request.WithNamespace(context.Background(), "default") - - t.Run("invalid resourceVersion", func(t *testing.T) { - req := httptest.NewRequest("POST", "/restore", bytes.NewReader([]byte(`{"resourceVersion":0}`))) - w := httptest.NewRecorder() - - expectedError := fmt.Errorf("resource version required") - mockResponder.On("Error", mock.MatchedBy(func(err error) bool { - return err.Error() == expectedError.Error() - })) - - handler, err := r.Connect(ctx, "test-uid", nil, mockResponder) - assert.NoError(t, err) - - handler.ServeHTTP(w, req) - mockResponder.AssertExpectations(t) - }) - - t.Run("happy path", func(t *testing.T) { - req := httptest.NewRequest("POST", "/restore", bytes.NewReader([]byte(`{"resourceVersion":123}`))) - w := httptest.NewRecorder() - restoreReq := &resource.RestoreRequest{ - ResourceVersion: 123, - Key: &resource.ResourceKey{ - Namespace: "default", - Group: "group", - Resource: "resource", - Name: "uid", - }, - } - - expectedObject := &metav1.PartialObjectMetadata{ - ObjectMeta: metav1.ObjectMeta{ - Name: "uid", - Namespace: "default", - ResourceVersion: strconv.FormatInt(123, 10), - }, - } - - mockClient.On("Restore", ctx, restoreReq).Return(&resource.RestoreResponse{ - ResourceVersion: 123, - }, nil).Once() - - mockResponder.On("Object", http.StatusOK, mock.MatchedBy(func(obj interface{}) bool { - metadata, ok := obj.(*metav1.PartialObjectMetadata) - return ok && - metadata.ObjectMeta.Name == "uid" && - metadata.ObjectMeta.Namespace == "default" && - metadata.ObjectMeta.ResourceVersion == "123" - })).Return(expectedObject) - - handler, err := r.Connect(ctx, "uid", nil, mockResponder) - assert.NoError(t, err) - handler.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - - mockClient.AssertExpectations(t) - mockResponder.AssertExpectations(t) - }) -} diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 33162ccabb6..236652d8dd5 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -459,7 +459,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use } if folderUidIdx == -1 { - return sharedDashboards, fmt.Errorf("Error retrieving folder information") + return sharedDashboards, fmt.Errorf("error retrieving folder information") } // populate list of unique folder UIDs in the list of dashboards user has read permissions diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index e393aabea62..90411b3cbbf 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -692,9 +692,6 @@ func (m *MockClient) Update(ctx context.Context, in *resource.UpdateRequest, opt func (m *MockClient) Read(ctx context.Context, in *resource.ReadRequest, opts ...grpc.CallOption) (*resource.ReadResponse, error) { return nil, nil } -func (m *MockClient) Restore(ctx context.Context, in *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) { - return nil, nil -} func (m *MockClient) GetBlob(ctx context.Context, in *resource.GetBlobRequest, opts ...grpc.CallOption) (*resource.GetBlobResponse, error) { return nil, nil } diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 0128d7d2e17..fd074aba343 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -265,8 +265,6 @@ type GetDashboardQuery struct { FolderID *int64 FolderUID *string OrgID int64 - - IncludeDeleted bool // only supported when using unified storage } type DashboardTagCloudItem struct { diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 9e26875ff4c..0a11d43f4d7 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -757,7 +757,7 @@ func (dr *DashboardServiceImpl) saveDashboard(ctx context.Context, cmd *dashboar func (dr *DashboardServiceImpl) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) { - return dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: orgID, UID: uid, IncludeDeleted: true}) + return dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: orgID, UID: uid}) } return dr.dashboardStore.GetSoftDeletedDashboard(ctx, orgID, uid) @@ -1520,12 +1520,6 @@ func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (i // ----------------------------------------------------------------------------------------- func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { - // if including deleted dashboards for restore, use the /latest subresource - subresource := "" - if query.IncludeDeleted && dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) { - subresource = "latest" - } - // get uid if not passed in if query.UID == "" { result, err := dr.GetDashboardUIDByID(ctx, &dashboards.GetDashboardRefByIDQuery{ @@ -1538,7 +1532,7 @@ func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, quer query.UID = result.UID } - out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, v1.GetOptions{}, subresource) + out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, v1.GetOptions{}, "") if err != nil && !apierrors.IsNotFound(err) { return nil, err } else if err != nil || out == nil { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index da680c84add..b1d30531435 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -656,12 +656,6 @@ var ( Owner: grafanaAppPlatformSquad, FrontendOnly: true, }, - { - Name: "kubernetesRestore", - Description: "Allow restoring objects in k8s", - Stage: FeatureStageExperimental, - Owner: grafanaAppPlatformSquad, - }, { Name: "kubernetesClientDashboardsFolders", Description: "Route the folder and dashboard service requests to k8s", diff --git a/pkg/services/featuremgmt/toggles-gitlog.csv b/pkg/services/featuremgmt/toggles-gitlog.csv index 0cdaace5e3e..cb90956b908 100644 --- a/pkg/services/featuremgmt/toggles-gitlog.csv +++ b/pkg/services/featuremgmt/toggles-gitlog.csv @@ -413,7 +413,6 @@ unifiedStorageSearchUI,2024-12-19T18:21:48Z,,a8f347144ddc16f2033fdeb4f3474e49239 playlistsReconciler,2024-12-20T03:09:31Z,,24bf337c562dc9b9d8684cc9acb7ea171ea83414,Charandas k8SFolderCounts,2024-12-27T17:10:44Z,,df36e77cd31d2ad77e3d708748d040367a0c8c9c,Leonor Oliveira k8SFolderMove,2024-12-27T17:10:44Z,,df36e77cd31d2ad77e3d708748d040367a0c8c9c,Leonor Oliveira -kubernetesRestore,2025-01-03T14:48:47Z,,5429512779bd5f25b88ff728ea91efdef7dfafa0,Stephanie Hingtgen improvedExternalSessionHandlingSAML,2025-01-09T17:02:49Z,,c52ec21c75ab72c2f7d28259bac0364edae560d0,Misi teamHttpHeadersMimir,2025-01-13T10:42:47Z,,04acbcdef23f673bd6bbfdbbece29c9769ce155a,Eric Leijonmarck ABTestFeatureToggleA,2025-01-13T21:13:13Z,,009d7f42b3d09b3a6be1f00f07314e2b25af7ebc,Nathan Marrs diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 9d6dc6fd264..32ec8d224b1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -86,7 +86,6 @@ formatString,GA,@grafana/dataviz-squad,false,false,true kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true -kubernetesRestore,experimental,@grafana/grafana-app-platform-squad,false,false,false kubernetesClientDashboardsFolders,experimental,@grafana/grafana-app-platform-squad,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 943895b2c9e..ed66a7b6e94 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -355,10 +355,6 @@ const ( // Use the kubernetes API in the frontend for dashboards FlagKubernetesDashboards = "kubernetesDashboards" - // FlagKubernetesRestore - // Allow restoring objects in k8s - FlagKubernetesRestore = "kubernetesRestore" - // FlagKubernetesClientDashboardsFolders // Route the folder and dashboard service requests to k8s FlagKubernetesClientDashboardsFolders = "kubernetesClientDashboardsFolders" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0d0fe4b0a57..fbd2602c7a3 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2404,18 +2404,6 @@ "expression": "true" } }, - { - "metadata": { - "name": "kubernetesRestore", - "resourceVersion": "1735880498698", - "creationTimestamp": "2025-01-03T14:48:47Z" - }, - "spec": { - "description": "Allow restoring objects in k8s", - "stage": "experimental", - "codeowner": "@grafana/grafana-app-platform-squad" - } - }, { "metadata": { "name": "kubernetesSnapshots", diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go index 8f8d7925e13..dfa9419ab3c 100644 --- a/pkg/storage/unified/resource/resource.pb.go +++ b/pkg/storage/unified/resource/resource.pb.go @@ -400,7 +400,7 @@ func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber { // Deprecated: Use PutBlobRequest_Method.Descriptor instead. func (PutBlobRequest_Method) EnumDescriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{36, 0} + return file_resource_proto_rawDescGZIP(), []int{34, 0} } type ResourceKey struct { @@ -1152,10 +1152,8 @@ type ReadRequest struct { Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Optionally pick an explicit resource version ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` - // Optionally decide to return the latest RV if deleted - IncludeDeleted bool `protobuf:"varint,3,opt,name=include_deleted,json=includeDeleted,proto3" json:"include_deleted,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReadRequest) Reset() { @@ -1202,13 +1200,6 @@ func (x *ReadRequest) GetResourceVersion() int64 { return 0 } -func (x *ReadRequest) GetIncludeDeleted() bool { - if x != nil { - return x.IncludeDeleted - } - return false -} - type ReadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Error details @@ -2844,114 +2835,6 @@ func (x *ResourceTableRow) GetObject() []byte { return nil } -type RestoreRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Full key must be set - Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - // The resource version to restore - ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RestoreRequest) Reset() { - *x = RestoreRequest{} - mi := &file_resource_proto_msgTypes[34] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RestoreRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RestoreRequest) ProtoMessage() {} - -func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[34] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. -func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{34} -} - -func (x *RestoreRequest) GetKey() *ResourceKey { - if x != nil { - return x.Key - } - return nil -} - -func (x *RestoreRequest) GetResourceVersion() int64 { - if x != nil { - return x.ResourceVersion - } - return 0 -} - -type RestoreResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Error details - Error *ErrorResult `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` - // The updated resource version - ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RestoreResponse) Reset() { - *x = RestoreResponse{} - mi := &file_resource_proto_msgTypes[35] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RestoreResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RestoreResponse) ProtoMessage() {} - -func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[35] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. -func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{35} -} - -func (x *RestoreResponse) GetError() *ErrorResult { - if x != nil { - return x.Error - } - return nil -} - -func (x *RestoreResponse) GetResourceVersion() int64 { - if x != nil { - return x.ResourceVersion - } - return 0 -} - type PutBlobRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The resource that will use this blob @@ -2970,7 +2853,7 @@ type PutBlobRequest struct { func (x *PutBlobRequest) Reset() { *x = PutBlobRequest{} - mi := &file_resource_proto_msgTypes[36] + mi := &file_resource_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2982,7 +2865,7 @@ func (x *PutBlobRequest) String() string { func (*PutBlobRequest) ProtoMessage() {} func (x *PutBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[36] + mi := &file_resource_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2995,7 +2878,7 @@ func (x *PutBlobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PutBlobRequest.ProtoReflect.Descriptor instead. func (*PutBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{36} + return file_resource_proto_rawDescGZIP(), []int{34} } func (x *PutBlobRequest) GetResource() *ResourceKey { @@ -3048,7 +2931,7 @@ type PutBlobResponse struct { func (x *PutBlobResponse) Reset() { *x = PutBlobResponse{} - mi := &file_resource_proto_msgTypes[37] + mi := &file_resource_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3060,7 +2943,7 @@ func (x *PutBlobResponse) String() string { func (*PutBlobResponse) ProtoMessage() {} func (x *PutBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[37] + mi := &file_resource_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3073,7 +2956,7 @@ func (x *PutBlobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PutBlobResponse.ProtoReflect.Descriptor instead. func (*PutBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{37} + return file_resource_proto_rawDescGZIP(), []int{35} } func (x *PutBlobResponse) GetError() *ErrorResult { @@ -3140,7 +3023,7 @@ type GetBlobRequest struct { func (x *GetBlobRequest) Reset() { *x = GetBlobRequest{} - mi := &file_resource_proto_msgTypes[38] + mi := &file_resource_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3152,7 +3035,7 @@ func (x *GetBlobRequest) String() string { func (*GetBlobRequest) ProtoMessage() {} func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[38] + mi := &file_resource_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3165,7 +3048,7 @@ func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBlobRequest.ProtoReflect.Descriptor instead. func (*GetBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{38} + return file_resource_proto_rawDescGZIP(), []int{36} } func (x *GetBlobRequest) GetResource() *ResourceKey { @@ -3214,7 +3097,7 @@ type GetBlobResponse struct { func (x *GetBlobResponse) Reset() { *x = GetBlobResponse{} - mi := &file_resource_proto_msgTypes[39] + mi := &file_resource_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3226,7 +3109,7 @@ func (x *GetBlobResponse) String() string { func (*GetBlobResponse) ProtoMessage() {} func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[39] + mi := &file_resource_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3239,7 +3122,7 @@ func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBlobResponse.ProtoReflect.Descriptor instead. func (*GetBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{39} + return file_resource_proto_rawDescGZIP(), []int{37} } func (x *GetBlobResponse) GetError() *ErrorResult { @@ -3280,7 +3163,7 @@ type WatchEvent_Resource struct { func (x *WatchEvent_Resource) Reset() { *x = WatchEvent_Resource{} - mi := &file_resource_proto_msgTypes[40] + mi := &file_resource_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3292,7 +3175,7 @@ func (x *WatchEvent_Resource) String() string { func (*WatchEvent_Resource) ProtoMessage() {} func (x *WatchEvent_Resource) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[40] + mi := &file_resource_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3339,7 +3222,7 @@ type BulkResponse_Summary struct { func (x *BulkResponse_Summary) Reset() { *x = BulkResponse_Summary{} - mi := &file_resource_proto_msgTypes[41] + mi := &file_resource_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3351,7 +3234,7 @@ func (x *BulkResponse_Summary) String() string { func (*BulkResponse_Summary) ProtoMessage() {} func (x *BulkResponse_Summary) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[41] + mi := &file_resource_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3435,7 +3318,7 @@ type BulkResponse_Rejected struct { func (x *BulkResponse_Rejected) Reset() { *x = BulkResponse_Rejected{} - mi := &file_resource_proto_msgTypes[42] + mi := &file_resource_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3447,7 +3330,7 @@ func (x *BulkResponse_Rejected) String() string { func (*BulkResponse_Rejected) ProtoMessage() {} func (x *BulkResponse_Rejected) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[42] + mi := &file_resource_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3498,7 +3381,7 @@ type ResourceStatsResponse_Stats struct { func (x *ResourceStatsResponse_Stats) Reset() { *x = ResourceStatsResponse_Stats{} - mi := &file_resource_proto_msgTypes[43] + mi := &file_resource_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3510,7 +3393,7 @@ func (x *ResourceStatsResponse_Stats) String() string { func (*ResourceStatsResponse_Stats) ProtoMessage() {} func (x *ResourceStatsResponse_Stats) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[43] + mi := &file_resource_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3557,7 +3440,7 @@ type ResourceSearchRequest_Sort struct { func (x *ResourceSearchRequest_Sort) Reset() { *x = ResourceSearchRequest_Sort{} - mi := &file_resource_proto_msgTypes[44] + mi := &file_resource_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3569,7 +3452,7 @@ func (x *ResourceSearchRequest_Sort) String() string { func (*ResourceSearchRequest_Sort) ProtoMessage() {} func (x *ResourceSearchRequest_Sort) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[44] + mi := &file_resource_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3609,7 +3492,7 @@ type ResourceSearchRequest_Facet struct { func (x *ResourceSearchRequest_Facet) Reset() { *x = ResourceSearchRequest_Facet{} - mi := &file_resource_proto_msgTypes[45] + mi := &file_resource_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3621,7 +3504,7 @@ func (x *ResourceSearchRequest_Facet) String() string { func (*ResourceSearchRequest_Facet) ProtoMessage() {} func (x *ResourceSearchRequest_Facet) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[45] + mi := &file_resource_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3666,7 +3549,7 @@ type ResourceSearchResponse_Facet struct { func (x *ResourceSearchResponse_Facet) Reset() { *x = ResourceSearchResponse_Facet{} - mi := &file_resource_proto_msgTypes[47] + mi := &file_resource_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3678,7 +3561,7 @@ func (x *ResourceSearchResponse_Facet) String() string { func (*ResourceSearchResponse_Facet) ProtoMessage() {} func (x *ResourceSearchResponse_Facet) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[47] + mi := &file_resource_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3732,7 +3615,7 @@ type ResourceSearchResponse_TermFacet struct { func (x *ResourceSearchResponse_TermFacet) Reset() { *x = ResourceSearchResponse_TermFacet{} - mi := &file_resource_proto_msgTypes[48] + mi := &file_resource_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3744,7 +3627,7 @@ func (x *ResourceSearchResponse_TermFacet) String() string { func (*ResourceSearchResponse_TermFacet) ProtoMessage() {} func (x *ResourceSearchResponse_TermFacet) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[48] + mi := &file_resource_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3794,7 +3677,7 @@ type ListManagedObjectsResponse_Item struct { func (x *ListManagedObjectsResponse_Item) Reset() { *x = ListManagedObjectsResponse_Item{} - mi := &file_resource_proto_msgTypes[50] + mi := &file_resource_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3806,7 +3689,7 @@ func (x *ListManagedObjectsResponse_Item) String() string { func (*ListManagedObjectsResponse_Item) ProtoMessage() {} func (x *ListManagedObjectsResponse_Item) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[50] + mi := &file_resource_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3877,7 +3760,7 @@ type CountManagedObjectsResponse_ResourceCount struct { func (x *CountManagedObjectsResponse_ResourceCount) Reset() { *x = CountManagedObjectsResponse_ResourceCount{} - mi := &file_resource_proto_msgTypes[51] + mi := &file_resource_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3889,7 +3772,7 @@ func (x *CountManagedObjectsResponse_ResourceCount) String() string { func (*CountManagedObjectsResponse_ResourceCount) ProtoMessage() {} func (x *CountManagedObjectsResponse_ResourceCount) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[51] + mi := &file_resource_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3962,7 +3845,7 @@ type ResourceTableColumnDefinition_Properties struct { func (x *ResourceTableColumnDefinition_Properties) Reset() { *x = ResourceTableColumnDefinition_Properties{} - mi := &file_resource_proto_msgTypes[52] + mi := &file_resource_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3974,7 +3857,7 @@ func (x *ResourceTableColumnDefinition_Properties) String() string { func (*ResourceTableColumnDefinition_Properties) ProtoMessage() {} func (x *ResourceTableColumnDefinition_Properties) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[52] + mi := &file_resource_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4107,529 +3990,509 @@ var file_resource_proto_rawDesc = string([]byte{ 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8a, 0x01, 0x0a, - 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x64, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, - 0x64, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x7c, 0x0a, 0x0c, 0x52, 0x65, 0x61, - 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, - 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x53, 0x0a, 0x0b, 0x52, 0x65, 0x71, 0x75, 0x69, - 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, - 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, - 0x61, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, - 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x27, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x6c, 0x61, - 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x66, 0x69, 0x65, - 0x6c, 0x64, 0x73, 0x22, 0xcf, 0x02, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, - 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, - 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x0d, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0c, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x6c, - 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, - 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x2b, 0x0a, 0x06, 0x53, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x4f, 0x52, 0x45, 0x10, 0x00, 0x12, 0x0b, 0x0a, - 0x07, 0x48, 0x49, 0x53, 0x54, 0x4f, 0x52, 0x59, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, - 0x41, 0x53, 0x48, 0x10, 0x02, 0x22, 0xf1, 0x01, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x57, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, - 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, - 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, - 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, - 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, - 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2b, 0x0a, 0x05, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xb9, 0x01, 0x0a, 0x0c, 0x57, 0x61, - 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, - 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, - 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, - 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, - 0x6c, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, - 0x73, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x73, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x77, 0x61, 0x74, 0x63, 0x68, - 0x5f, 0x62, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x13, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x57, 0x61, 0x74, 0x63, 0x68, 0x42, 0x6f, 0x6f, 0x6b, - 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x22, 0xdf, 0x02, 0x0a, 0x0a, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, - 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, - 0x6d, 0x70, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, - 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, - 0x65, 0x12, 0x39, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, - 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x08, - 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, - 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x70, - 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x1a, 0x3a, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, - 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45, - 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, - 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0c, - 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, 0x4d, 0x41, 0x52, 0x4b, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, - 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0xd7, 0x01, 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x34, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, - 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06, - 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, - 0x6c, 0x64, 0x65, 0x72, 0x22, 0x3b, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b, - 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41, - 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, - 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, - 0x03, 0x22, 0xda, 0x04, 0x0a, 0x0c, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, - 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, - 0x1c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x65, 0x64, 0x12, 0x38, 0x0a, - 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x07, - 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x3b, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, - 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, - 0x63, 0x74, 0x65, 0x64, 0x1a, 0x86, 0x02, 0x0a, 0x07, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, - 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, - 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, - 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, - 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, - 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x70, - 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x43, 0x6f, 0x75, - 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x68, - 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, - 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x1a, 0x7f, 0x0a, - 0x08, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, - 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x62, - 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, - 0x6c, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, - 0x65, 0x72, 0x22, 0xd2, 0x01, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, - 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, - 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x1a, 0x4f, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, - 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x8e, 0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x66, 0x65, 0x64, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x09, 0x66, 0x65, - 0x64, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x14, 0x0a, - 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, - 0x6d, 0x69, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x12, 0x3c, 0x0a, 0x06, 0x73, - 0x6f, 0x72, 0x74, 0x42, 0x79, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x6f, 0x72, - 0x74, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x40, 0x0a, 0x05, 0x66, 0x61, 0x63, - 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x66, - 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x66, 0x69, 0x65, - 0x6c, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x78, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x78, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x12, 0x1d, 0x0a, - 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, - 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, - 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, - 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, - 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, - 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, - 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x1a, 0x5f, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xea, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, - 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, - 0x62, 0x6c, 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, - 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x48, 0x69, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71, - 0x75, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, - 0x09, 0x71, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, - 0x78, 0x5f, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d, - 0x61, 0x78, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x41, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, - 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46, - 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, - 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, - 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65, - 0x72, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d, - 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09, - 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72, - 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a, - 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x1a, 0x60, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x3c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x85, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, - 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, - 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x6e, - 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, - 0x02, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0xd4, 0x02, - 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x05, - 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x61, 0x0a, 0x0b, + 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, + 0x7c, 0x0a, 0x0c, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, + 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x10, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x53, 0x0a, + 0x0b, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1a, + 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x06, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69, + 0x65, 0x6c, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x22, 0xcf, 0x02, 0x0a, 0x0b, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, + 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x0d, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, + 0x74, 0x63, 0x68, 0x52, 0x0c, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, + 0x68, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, + 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x2b, + 0x0a, 0x06, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x4f, 0x52, + 0x45, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x49, 0x53, 0x54, 0x4f, 0x52, 0x59, 0x10, 0x01, + 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x53, 0x48, 0x10, 0x02, 0x22, 0xf1, 0x01, 0x0a, 0x0c, + 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x05, + 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x57, + 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x1a, 0x9f, 0x01, 0x0a, 0x04, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x2d, 0x0a, 0x06, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, - 0x65, 0x79, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, - 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x12, - 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, - 0x73, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, - 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, - 0x6c, 0x64, 0x65, 0x72, 0x22, 0x5e, 0x0a, 0x1a, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x22, 0x92, 0x02, 0x0a, 0x1b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, - 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, - 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, - 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0x7b, 0x0a, 0x0d, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x12, 0x0a, - 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, - 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, - 0x64, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x2e, 0x0a, 0x12, 0x48, 0x65, 0x61, - 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xab, 0x01, 0x0a, 0x13, 0x48, 0x65, - 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, - 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x4f, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, - 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, - 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, - 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, - 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, - 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0x87, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6c, - 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, - 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, - 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x04, - 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, - 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x52, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x12, 0x26, 0x0a, 0x0f, - 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, - 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, - 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, - 0x74, 0x22, 0xf1, 0x04, 0x0a, 0x1d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, - 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x46, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, - 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x43, - 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x72, 0x72, 0x61, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x72, 0x72, 0x61, 0x79, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, - 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x52, 0x0a, 0x0a, - 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, - 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, - 0x74, 0x69, 0x65, 0x73, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, - 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x1a, 0xae, 0x01, 0x0a, - 0x0a, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x75, - 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, - 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x65, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x08, 0x66, 0x72, 0x65, 0x65, 0x54, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, - 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x19, 0x0a, - 0x08, 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x07, 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x66, 0x61, - 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x95, 0x01, - 0x0a, 0x0a, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c, - 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x10, 0x00, 0x12, 0x0a, - 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x42, 0x4f, - 0x4f, 0x4c, 0x45, 0x41, 0x4e, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x33, 0x32, - 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x36, 0x34, 0x10, 0x04, 0x12, 0x09, 0x0a, - 0x05, 0x46, 0x4c, 0x4f, 0x41, 0x54, 0x10, 0x05, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x4f, 0x55, 0x42, - 0x4c, 0x45, 0x10, 0x06, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x41, 0x54, 0x45, 0x10, 0x07, 0x12, 0x0d, - 0x0a, 0x09, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x49, 0x4d, 0x45, 0x10, 0x08, 0x12, 0x0a, 0x0a, - 0x06, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x10, 0x09, 0x12, 0x0a, 0x0a, 0x06, 0x4f, 0x42, 0x4a, - 0x45, 0x43, 0x54, 0x10, 0x0a, 0x22, 0x94, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, - 0x0a, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x63, - 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x64, 0x0a, 0x0e, - 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, - 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x22, 0x69, 0x0a, 0x0f, 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xd3, 0x01, - 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, - 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, - 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x21, 0x0a, 0x0c, - 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, - 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, - 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, - 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, - 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, - 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0xaa, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x42, - 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29, 0x0a, - 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x75, 0x73, 0x74, - 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x42, 0x79, 0x74, - 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x75, 0x69, 0x64, 0x22, 0x89, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, + 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, + 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0xb9, 0x01, 0x0a, 0x0c, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, + 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x5f, + 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x73, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, + 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x6c, 0x6c, 0x6f, 0x77, + 0x5f, 0x77, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x62, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x42, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x22, 0xdf, 0x02, 0x0a, 0x0a, + 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79, 0x70, + 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x1a, 0x3a, 0x0a, + 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, + 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, + 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, + 0x45, 0x44, 0x10, 0x03, 0x12, 0x0c, 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, 0x4d, 0x41, 0x52, 0x4b, + 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0xd7, 0x01, + 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, + 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x3b, 0x0a, 0x06, 0x41, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, + 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, + 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, + 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x22, 0xda, 0x04, 0x0a, 0x0c, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, - 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x2a, 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x4f, - 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x78, - 0x61, 0x63, 0x74, 0x10, 0x01, 0x32, 0xad, 0x03, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, - 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, - 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65, - 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x74, - 0x6f, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x05, - 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, - 0x65, 0x6e, 0x74, 0x30, 0x01, 0x32, 0x4b, 0x0a, 0x09, 0x42, 0x75, 0x6c, 0x6b, 0x53, 0x74, 0x6f, - 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, - 0x73, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, - 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x28, 0x01, 0x32, 0xa9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1f, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, + 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, + 0x73, 0x65, 0x64, 0x12, 0x38, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x75, 0x6d, + 0x6d, 0x61, 0x72, 0x79, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x3b, 0x0a, + 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, + 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x1a, 0x86, 0x02, 0x0a, 0x07, 0x53, + 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, + 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x68, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x76, 0x69, + 0x6f, 0x75, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, + 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x48, 0x69, 0x73, 0x74, + 0x6f, 0x72, 0x79, 0x1a, 0x7f, 0x0a, 0x08, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, + 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, + 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, + 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x22, 0x62, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, + 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6b, 0x69, + 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, + 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0xd2, 0x01, 0x0a, 0x15, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, + 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x1a, 0x4f, 0x0a, 0x05, + 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x8e, 0x05, + 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x66, 0x65, 0x64, 0x65, + 0x72, 0x61, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, + 0x65, 0x79, 0x52, 0x09, 0x66, 0x65, 0x64, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x12, 0x14, 0x0a, + 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, + 0x73, 0x65, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, + 0x74, 0x12, 0x3c, 0x0a, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x2e, 0x53, 0x6f, 0x72, 0x74, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, + 0x40, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, + 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, + 0x74, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x78, 0x70, + 0x6c, 0x61, 0x69, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x78, 0x70, 0x6c, + 0x61, 0x69, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12, 0x14, + 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, + 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, 0x61, 0x63, 0x65, + 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x1a, 0x5f, 0x0a, + 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3b, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, + 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xea, + 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x74, 0x73, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x48, 0x69, 0x74, + 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, 0x71, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x73, 0x74, + 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x41, 0x0a, + 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, + 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, + 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, + 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, + 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, + 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, + 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x4b, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1e, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xd9, - 0x01, 0x0a, 0x12, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x62, 0x0a, 0x13, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, - 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x12, 0x4c, 0x69, 0x73, - 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, - 0x23, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x74, 0x65, 0x72, + 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, + 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, + 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x1a, 0x60, 0x0a, 0x0a, 0x46, 0x61, 0x63, + 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3c, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x85, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42, - 0x6c, 0x6f, 0x62, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42, - 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, - 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42, - 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, - 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, - 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, - 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, - 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, - 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, - 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, + 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x22, 0xd4, 0x02, 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x29, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, + 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, + 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2b, 0x0a, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0x9f, 0x01, 0x0a, 0x04, 0x49, 0x74, 0x65, + 0x6d, 0x12, 0x2d, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x70, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, + 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, + 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x5e, 0x0a, 0x1a, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, + 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x92, 0x02, 0x0a, 0x1b, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x05, 0x69, 0x74, + 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x05, + 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x1a, 0x7b, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, + 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, + 0x2e, 0x0a, 0x12, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, + 0xab, 0x01, 0x0a, 0x13, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x4f, 0x0a, 0x0d, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, + 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, + 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, + 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, 0x56, + 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0x87, 0x02, + 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, + 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x27, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, + 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, + 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x52, 0x04, 0x72, 0x6f, + 0x77, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, + 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, + 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, 0x74, + 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xf1, 0x04, 0x0a, 0x1d, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, + 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x46, 0x0a, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x32, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, + 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x72, 0x72, 0x61, + 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x72, 0x72, 0x61, 0x79, + 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x52, 0x0a, 0x0a, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, + 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, + 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x70, + 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, + 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, + 0x74, 0x79, 0x1a, 0xae, 0x01, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, + 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x65, 0x5f, 0x74, + 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x66, 0x72, 0x65, 0x65, 0x54, + 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, 0x62, 0x6c, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, + 0x62, 0x6c, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c, 0x6c, 0x12, 0x23, + 0x0a, 0x0d, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x22, 0x95, 0x01, 0x0a, 0x0a, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, 0x01, + 0x12, 0x0b, 0x0a, 0x07, 0x42, 0x4f, 0x4f, 0x4c, 0x45, 0x41, 0x4e, 0x10, 0x02, 0x12, 0x09, 0x0a, + 0x05, 0x49, 0x4e, 0x54, 0x33, 0x32, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x36, + 0x34, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x4c, 0x4f, 0x41, 0x54, 0x10, 0x05, 0x12, 0x0a, + 0x0a, 0x06, 0x44, 0x4f, 0x55, 0x42, 0x4c, 0x45, 0x10, 0x06, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x41, + 0x54, 0x45, 0x10, 0x07, 0x12, 0x0d, 0x0a, 0x09, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x49, 0x4d, + 0x45, 0x10, 0x08, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x10, 0x09, 0x12, + 0x0a, 0x0a, 0x06, 0x4f, 0x42, 0x4a, 0x45, 0x43, 0x54, 0x10, 0x0a, 0x22, 0x94, 0x01, 0x0a, 0x10, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, + 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x22, 0xd3, 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, + 0x6f, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, + 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, 0x65, + 0x74, 0x68, 0x6f, 0x64, 0x12, 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, 0x08, + 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f, 0x50, 0x75, 0x74, + 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, + 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, + 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, + 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0xaa, 0x01, 0x0a, + 0x0e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, + 0x10, 0x6d, 0x75, 0x73, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, 0x65, + 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x22, 0x89, 0x01, 0x0a, 0x0f, 0x47, 0x65, + 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, + 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2a, 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, + 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, + 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, + 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, + 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, + 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, + 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x32, 0x4b, 0x0a, 0x09, 0x42, 0x75, + 0x6c, 0x6b, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b, 0x50, + 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x32, 0xa9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x32, 0xd9, 0x01, 0x0a, 0x12, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x62, 0x0a, 0x13, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x73, 0x12, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, + 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x73, 0x12, 0x23, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0x8b, 0x01, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x62, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, 0x0a, + 0x07, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, + 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, + 0x07, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, + 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, 0x0a, + 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, 0x09, + 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, + 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, + 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -4645,7 +4508,7 @@ func file_resource_proto_rawDescGZIP() []byte { } var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 7) -var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 53) +var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 51) var file_resource_proto_goTypes = []any{ (ResourceVersionMatch)(0), // 0: resource.ResourceVersionMatch (ListRequest_Source)(0), // 1: resource.ListRequest.Source @@ -4688,25 +4551,23 @@ var file_resource_proto_goTypes = []any{ (*ResourceTable)(nil), // 38: resource.ResourceTable (*ResourceTableColumnDefinition)(nil), // 39: resource.ResourceTableColumnDefinition (*ResourceTableRow)(nil), // 40: resource.ResourceTableRow - (*RestoreRequest)(nil), // 41: resource.RestoreRequest - (*RestoreResponse)(nil), // 42: resource.RestoreResponse - (*PutBlobRequest)(nil), // 43: resource.PutBlobRequest - (*PutBlobResponse)(nil), // 44: resource.PutBlobResponse - (*GetBlobRequest)(nil), // 45: resource.GetBlobRequest - (*GetBlobResponse)(nil), // 46: resource.GetBlobResponse - (*WatchEvent_Resource)(nil), // 47: resource.WatchEvent.Resource - (*BulkResponse_Summary)(nil), // 48: resource.BulkResponse.Summary - (*BulkResponse_Rejected)(nil), // 49: resource.BulkResponse.Rejected - (*ResourceStatsResponse_Stats)(nil), // 50: resource.ResourceStatsResponse.Stats - (*ResourceSearchRequest_Sort)(nil), // 51: resource.ResourceSearchRequest.Sort - (*ResourceSearchRequest_Facet)(nil), // 52: resource.ResourceSearchRequest.Facet - nil, // 53: resource.ResourceSearchRequest.FacetEntry - (*ResourceSearchResponse_Facet)(nil), // 54: resource.ResourceSearchResponse.Facet - (*ResourceSearchResponse_TermFacet)(nil), // 55: resource.ResourceSearchResponse.TermFacet - nil, // 56: resource.ResourceSearchResponse.FacetEntry - (*ListManagedObjectsResponse_Item)(nil), // 57: resource.ListManagedObjectsResponse.Item - (*CountManagedObjectsResponse_ResourceCount)(nil), // 58: resource.CountManagedObjectsResponse.ResourceCount - (*ResourceTableColumnDefinition_Properties)(nil), // 59: resource.ResourceTableColumnDefinition.Properties + (*PutBlobRequest)(nil), // 41: resource.PutBlobRequest + (*PutBlobResponse)(nil), // 42: resource.PutBlobResponse + (*GetBlobRequest)(nil), // 43: resource.GetBlobRequest + (*GetBlobResponse)(nil), // 44: resource.GetBlobResponse + (*WatchEvent_Resource)(nil), // 45: resource.WatchEvent.Resource + (*BulkResponse_Summary)(nil), // 46: resource.BulkResponse.Summary + (*BulkResponse_Rejected)(nil), // 47: resource.BulkResponse.Rejected + (*ResourceStatsResponse_Stats)(nil), // 48: resource.ResourceStatsResponse.Stats + (*ResourceSearchRequest_Sort)(nil), // 49: resource.ResourceSearchRequest.Sort + (*ResourceSearchRequest_Facet)(nil), // 50: resource.ResourceSearchRequest.Facet + nil, // 51: resource.ResourceSearchRequest.FacetEntry + (*ResourceSearchResponse_Facet)(nil), // 52: resource.ResourceSearchResponse.Facet + (*ResourceSearchResponse_TermFacet)(nil), // 53: resource.ResourceSearchResponse.TermFacet + nil, // 54: resource.ResourceSearchResponse.FacetEntry + (*ListManagedObjectsResponse_Item)(nil), // 55: resource.ListManagedObjectsResponse.Item + (*CountManagedObjectsResponse_ResourceCount)(nil), // 56: resource.CountManagedObjectsResponse.ResourceCount + (*ResourceTableColumnDefinition_Properties)(nil), // 57: resource.ResourceTableColumnDefinition.Properties } var file_resource_proto_depIdxs = []int32{ 10, // 0: resource.ErrorResult.details:type_name -> resource.ErrorDetails @@ -4729,81 +4590,77 @@ var file_resource_proto_depIdxs = []int32{ 9, // 17: resource.ListResponse.error:type_name -> resource.ErrorResult 21, // 18: resource.WatchRequest.options:type_name -> resource.ListOptions 2, // 19: resource.WatchEvent.type:type_name -> resource.WatchEvent.Type - 47, // 20: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource - 47, // 21: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource + 45, // 20: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource + 45, // 21: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource 7, // 22: resource.BulkRequest.key:type_name -> resource.ResourceKey 3, // 23: resource.BulkRequest.action:type_name -> resource.BulkRequest.Action 9, // 24: resource.BulkResponse.error:type_name -> resource.ErrorResult - 48, // 25: resource.BulkResponse.summary:type_name -> resource.BulkResponse.Summary - 49, // 26: resource.BulkResponse.rejected:type_name -> resource.BulkResponse.Rejected + 46, // 25: resource.BulkResponse.summary:type_name -> resource.BulkResponse.Summary + 47, // 26: resource.BulkResponse.rejected:type_name -> resource.BulkResponse.Rejected 9, // 27: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult - 50, // 28: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats + 48, // 28: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats 21, // 29: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions 7, // 30: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey - 51, // 31: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort - 53, // 32: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry + 49, // 31: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort + 51, // 32: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry 9, // 33: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult 7, // 34: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey 38, // 35: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable - 56, // 36: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry - 57, // 37: resource.ListManagedObjectsResponse.items:type_name -> resource.ListManagedObjectsResponse.Item + 54, // 36: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry + 55, // 37: resource.ListManagedObjectsResponse.items:type_name -> resource.ListManagedObjectsResponse.Item 9, // 38: resource.ListManagedObjectsResponse.error:type_name -> resource.ErrorResult - 58, // 39: resource.CountManagedObjectsResponse.items:type_name -> resource.CountManagedObjectsResponse.ResourceCount + 56, // 39: resource.CountManagedObjectsResponse.items:type_name -> resource.CountManagedObjectsResponse.ResourceCount 9, // 40: resource.CountManagedObjectsResponse.error:type_name -> resource.ErrorResult 4, // 41: resource.HealthCheckResponse.status:type_name -> resource.HealthCheckResponse.ServingStatus 39, // 42: resource.ResourceTable.columns:type_name -> resource.ResourceTableColumnDefinition 40, // 43: resource.ResourceTable.rows:type_name -> resource.ResourceTableRow 5, // 44: resource.ResourceTableColumnDefinition.type:type_name -> resource.ResourceTableColumnDefinition.ColumnType - 59, // 45: resource.ResourceTableColumnDefinition.properties:type_name -> resource.ResourceTableColumnDefinition.Properties + 57, // 45: resource.ResourceTableColumnDefinition.properties:type_name -> resource.ResourceTableColumnDefinition.Properties 7, // 46: resource.ResourceTableRow.key:type_name -> resource.ResourceKey - 7, // 47: resource.RestoreRequest.key:type_name -> resource.ResourceKey - 9, // 48: resource.RestoreResponse.error:type_name -> resource.ErrorResult - 7, // 49: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey - 6, // 50: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method - 9, // 51: resource.PutBlobResponse.error:type_name -> resource.ErrorResult - 7, // 52: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey - 9, // 53: resource.GetBlobResponse.error:type_name -> resource.ErrorResult - 7, // 54: resource.BulkResponse.Rejected.key:type_name -> resource.ResourceKey - 3, // 55: resource.BulkResponse.Rejected.action:type_name -> resource.BulkRequest.Action - 52, // 56: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet - 55, // 57: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet - 54, // 58: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet - 7, // 59: resource.ListManagedObjectsResponse.Item.object:type_name -> resource.ResourceKey - 18, // 60: resource.ResourceStore.Read:input_type -> resource.ReadRequest - 12, // 61: resource.ResourceStore.Create:input_type -> resource.CreateRequest - 14, // 62: resource.ResourceStore.Update:input_type -> resource.UpdateRequest - 16, // 63: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest - 41, // 64: resource.ResourceStore.Restore:input_type -> resource.RestoreRequest - 22, // 65: resource.ResourceStore.List:input_type -> resource.ListRequest - 24, // 66: resource.ResourceStore.Watch:input_type -> resource.WatchRequest - 26, // 67: resource.BulkStore.BulkProcess:input_type -> resource.BulkRequest - 30, // 68: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest - 28, // 69: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest - 34, // 70: resource.ManagedObjectIndex.CountManagedObjects:input_type -> resource.CountManagedObjectsRequest - 32, // 71: resource.ManagedObjectIndex.ListManagedObjects:input_type -> resource.ListManagedObjectsRequest - 43, // 72: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest - 45, // 73: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest - 36, // 74: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest - 19, // 75: resource.ResourceStore.Read:output_type -> resource.ReadResponse - 13, // 76: resource.ResourceStore.Create:output_type -> resource.CreateResponse - 15, // 77: resource.ResourceStore.Update:output_type -> resource.UpdateResponse - 17, // 78: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse - 42, // 79: resource.ResourceStore.Restore:output_type -> resource.RestoreResponse - 23, // 80: resource.ResourceStore.List:output_type -> resource.ListResponse - 25, // 81: resource.ResourceStore.Watch:output_type -> resource.WatchEvent - 27, // 82: resource.BulkStore.BulkProcess:output_type -> resource.BulkResponse - 31, // 83: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse - 29, // 84: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse - 35, // 85: resource.ManagedObjectIndex.CountManagedObjects:output_type -> resource.CountManagedObjectsResponse - 33, // 86: resource.ManagedObjectIndex.ListManagedObjects:output_type -> resource.ListManagedObjectsResponse - 44, // 87: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse - 46, // 88: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse - 37, // 89: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse - 75, // [75:90] is the sub-list for method output_type - 60, // [60:75] is the sub-list for method input_type - 60, // [60:60] is the sub-list for extension type_name - 60, // [60:60] is the sub-list for extension extendee - 0, // [0:60] is the sub-list for field type_name + 7, // 47: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey + 6, // 48: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method + 9, // 49: resource.PutBlobResponse.error:type_name -> resource.ErrorResult + 7, // 50: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey + 9, // 51: resource.GetBlobResponse.error:type_name -> resource.ErrorResult + 7, // 52: resource.BulkResponse.Rejected.key:type_name -> resource.ResourceKey + 3, // 53: resource.BulkResponse.Rejected.action:type_name -> resource.BulkRequest.Action + 50, // 54: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet + 53, // 55: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet + 52, // 56: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet + 7, // 57: resource.ListManagedObjectsResponse.Item.object:type_name -> resource.ResourceKey + 18, // 58: resource.ResourceStore.Read:input_type -> resource.ReadRequest + 12, // 59: resource.ResourceStore.Create:input_type -> resource.CreateRequest + 14, // 60: resource.ResourceStore.Update:input_type -> resource.UpdateRequest + 16, // 61: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest + 22, // 62: resource.ResourceStore.List:input_type -> resource.ListRequest + 24, // 63: resource.ResourceStore.Watch:input_type -> resource.WatchRequest + 26, // 64: resource.BulkStore.BulkProcess:input_type -> resource.BulkRequest + 30, // 65: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest + 28, // 66: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest + 34, // 67: resource.ManagedObjectIndex.CountManagedObjects:input_type -> resource.CountManagedObjectsRequest + 32, // 68: resource.ManagedObjectIndex.ListManagedObjects:input_type -> resource.ListManagedObjectsRequest + 41, // 69: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest + 43, // 70: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest + 36, // 71: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest + 19, // 72: resource.ResourceStore.Read:output_type -> resource.ReadResponse + 13, // 73: resource.ResourceStore.Create:output_type -> resource.CreateResponse + 15, // 74: resource.ResourceStore.Update:output_type -> resource.UpdateResponse + 17, // 75: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse + 23, // 76: resource.ResourceStore.List:output_type -> resource.ListResponse + 25, // 77: resource.ResourceStore.Watch:output_type -> resource.WatchEvent + 27, // 78: resource.BulkStore.BulkProcess:output_type -> resource.BulkResponse + 31, // 79: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse + 29, // 80: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse + 35, // 81: resource.ManagedObjectIndex.CountManagedObjects:output_type -> resource.CountManagedObjectsResponse + 33, // 82: resource.ManagedObjectIndex.ListManagedObjects:output_type -> resource.ListManagedObjectsResponse + 42, // 83: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse + 44, // 84: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse + 37, // 85: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse + 72, // [72:86] is the sub-list for method output_type + 58, // [58:72] is the sub-list for method input_type + 58, // [58:58] is the sub-list for extension type_name + 58, // [58:58] is the sub-list for extension extendee + 0, // [0:58] is the sub-list for field type_name } func init() { file_resource_proto_init() } @@ -4817,7 +4674,7 @@ func file_resource_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_resource_proto_rawDesc), len(file_resource_proto_rawDesc)), NumEnums: 7, - NumMessages: 53, + NumMessages: 51, NumExtensions: 0, NumServices: 6, }, diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto index 830c2a4d890..55b48842df1 100644 --- a/pkg/storage/unified/resource/resource.proto +++ b/pkg/storage/unified/resource/resource.proto @@ -177,8 +177,6 @@ message ReadRequest { // Optionally pick an explicit resource version int64 resource_version = 2; - // Optionally decide to return the latest RV if deleted - bool include_deleted = 3; } message ReadResponse { @@ -702,26 +700,6 @@ message ResourceTableRow { bytes object = 4; } -//---------------------------- -// Restore Support -//---------------------------- - -message RestoreRequest { - // Full key must be set - ResourceKey key = 1; - - // The resource version to restore - int64 resource_version = 2; -} - -message RestoreResponse { - // Error details - ErrorResult error = 1; - - // The updated resource version - int64 resource_version = 2; -} - //---------------------------- // Blob Support //---------------------------- @@ -811,7 +789,6 @@ service ResourceStore { rpc Create(CreateRequest) returns (CreateResponse); rpc Update(UpdateRequest) returns (UpdateResponse); rpc Delete(DeleteRequest) returns (DeleteResponse); - rpc Restore(RestoreRequest) returns (RestoreResponse); // The results *may* include values that should not be returned to the user // This will perform best-effort filtering to increase performace. diff --git a/pkg/storage/unified/resource/resource_grpc.pb.go b/pkg/storage/unified/resource/resource_grpc.pb.go index 25c4a937f9c..950788f001e 100644 --- a/pkg/storage/unified/resource/resource_grpc.pb.go +++ b/pkg/storage/unified/resource/resource_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.4.0 +// - protoc-gen-go-grpc v1.5.1 // - protoc (unknown) // source: resource.proto @@ -15,17 +15,16 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.62.0 or later. -const _ = grpc.SupportPackageIsVersion8 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 const ( - ResourceStore_Read_FullMethodName = "/resource.ResourceStore/Read" - ResourceStore_Create_FullMethodName = "/resource.ResourceStore/Create" - ResourceStore_Update_FullMethodName = "/resource.ResourceStore/Update" - ResourceStore_Delete_FullMethodName = "/resource.ResourceStore/Delete" - ResourceStore_Restore_FullMethodName = "/resource.ResourceStore/Restore" - ResourceStore_List_FullMethodName = "/resource.ResourceStore/List" - ResourceStore_Watch_FullMethodName = "/resource.ResourceStore/Watch" + ResourceStore_Read_FullMethodName = "/resource.ResourceStore/Read" + ResourceStore_Create_FullMethodName = "/resource.ResourceStore/Create" + ResourceStore_Update_FullMethodName = "/resource.ResourceStore/Update" + ResourceStore_Delete_FullMethodName = "/resource.ResourceStore/Delete" + ResourceStore_List_FullMethodName = "/resource.ResourceStore/List" + ResourceStore_Watch_FullMethodName = "/resource.ResourceStore/Watch" ) // ResourceStoreClient is the client API for ResourceStore service. @@ -41,7 +40,6 @@ type ResourceStoreClient interface { Create(ctx context.Context, in *CreateRequest, opts ...grpc.CallOption) (*CreateResponse, error) Update(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*UpdateResponse, error) Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*DeleteResponse, error) - Restore(ctx context.Context, in *RestoreRequest, opts ...grpc.CallOption) (*RestoreResponse, error) // The results *may* include values that should not be returned to the user // This will perform best-effort filtering to increase performace. // NOTE: storage.Interface is ultimatly responsible for the final filtering @@ -49,7 +47,7 @@ type ResourceStoreClient interface { // The results *may* include values that should not be returned to the user // This will perform best-effort filtering to increase performace. // NOTE: storage.Interface is ultimatly responsible for the final filtering - Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (ResourceStore_WatchClient, error) + Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[WatchEvent], error) } type resourceStoreClient struct { @@ -100,16 +98,6 @@ func (c *resourceStoreClient) Delete(ctx context.Context, in *DeleteRequest, opt return out, nil } -func (c *resourceStoreClient) Restore(ctx context.Context, in *RestoreRequest, opts ...grpc.CallOption) (*RestoreResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RestoreResponse) - err := c.cc.Invoke(ctx, ResourceStore_Restore_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - func (c *resourceStoreClient) List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListResponse) @@ -120,13 +108,13 @@ func (c *resourceStoreClient) List(ctx context.Context, in *ListRequest, opts .. return out, nil } -func (c *resourceStoreClient) Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (ResourceStore_WatchClient, error) { +func (c *resourceStoreClient) Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[WatchEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &ResourceStore_ServiceDesc.Streams[0], ResourceStore_Watch_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &resourceStoreWatchClient{ClientStream: stream} + x := &grpc.GenericClientStream[WatchRequest, WatchEvent]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -136,26 +124,12 @@ func (c *resourceStoreClient) Watch(ctx context.Context, in *WatchRequest, opts return x, nil } -type ResourceStore_WatchClient interface { - Recv() (*WatchEvent, error) - grpc.ClientStream -} - -type resourceStoreWatchClient struct { - grpc.ClientStream -} - -func (x *resourceStoreWatchClient) Recv() (*WatchEvent, error) { - m := new(WatchEvent) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ResourceStore_WatchClient = grpc.ServerStreamingClient[WatchEvent] // ResourceStoreServer is the server API for ResourceStore service. // All implementations should embed UnimplementedResourceStoreServer -// for forward compatibility +// for forward compatibility. // // This provides the CRUD+List+Watch support needed for a k8s apiserver // The semantics and behaviors of this service are constrained by kubernetes @@ -166,7 +140,6 @@ type ResourceStoreServer interface { Create(context.Context, *CreateRequest) (*CreateResponse, error) Update(context.Context, *UpdateRequest) (*UpdateResponse, error) Delete(context.Context, *DeleteRequest) (*DeleteResponse, error) - Restore(context.Context, *RestoreRequest) (*RestoreResponse, error) // The results *may* include values that should not be returned to the user // This will perform best-effort filtering to increase performace. // NOTE: storage.Interface is ultimatly responsible for the final filtering @@ -174,12 +147,15 @@ type ResourceStoreServer interface { // The results *may* include values that should not be returned to the user // This will perform best-effort filtering to increase performace. // NOTE: storage.Interface is ultimatly responsible for the final filtering - Watch(*WatchRequest, ResourceStore_WatchServer) error + Watch(*WatchRequest, grpc.ServerStreamingServer[WatchEvent]) error } -// UnimplementedResourceStoreServer should be embedded to have forward compatible implementations. -type UnimplementedResourceStoreServer struct { -} +// UnimplementedResourceStoreServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedResourceStoreServer struct{} func (UnimplementedResourceStoreServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Read not implemented") @@ -193,15 +169,13 @@ func (UnimplementedResourceStoreServer) Update(context.Context, *UpdateRequest) func (UnimplementedResourceStoreServer) Delete(context.Context, *DeleteRequest) (*DeleteResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented") } -func (UnimplementedResourceStoreServer) Restore(context.Context, *RestoreRequest) (*RestoreResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Restore not implemented") -} func (UnimplementedResourceStoreServer) List(context.Context, *ListRequest) (*ListResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method List not implemented") } -func (UnimplementedResourceStoreServer) Watch(*WatchRequest, ResourceStore_WatchServer) error { +func (UnimplementedResourceStoreServer) Watch(*WatchRequest, grpc.ServerStreamingServer[WatchEvent]) error { return status.Errorf(codes.Unimplemented, "method Watch not implemented") } +func (UnimplementedResourceStoreServer) testEmbeddedByValue() {} // UnsafeResourceStoreServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ResourceStoreServer will @@ -211,6 +185,13 @@ type UnsafeResourceStoreServer interface { } func RegisterResourceStoreServer(s grpc.ServiceRegistrar, srv ResourceStoreServer) { + // If the following call pancis, it indicates UnimplementedResourceStoreServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&ResourceStore_ServiceDesc, srv) } @@ -286,24 +267,6 @@ func _ResourceStore_Delete_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } -func _ResourceStore_Restore_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RestoreRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ResourceStoreServer).Restore(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ResourceStore_Restore_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ResourceStoreServer).Restore(ctx, req.(*RestoreRequest)) - } - return interceptor(ctx, in, info, handler) -} - func _ResourceStore_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListRequest) if err := dec(in); err != nil { @@ -327,21 +290,11 @@ func _ResourceStore_Watch_Handler(srv interface{}, stream grpc.ServerStream) err if err := stream.RecvMsg(m); err != nil { return err } - return srv.(ResourceStoreServer).Watch(m, &resourceStoreWatchServer{ServerStream: stream}) + return srv.(ResourceStoreServer).Watch(m, &grpc.GenericServerStream[WatchRequest, WatchEvent]{ServerStream: stream}) } -type ResourceStore_WatchServer interface { - Send(*WatchEvent) error - grpc.ServerStream -} - -type resourceStoreWatchServer struct { - grpc.ServerStream -} - -func (x *resourceStoreWatchServer) Send(m *WatchEvent) error { - return x.ServerStream.SendMsg(m) -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ResourceStore_WatchServer = grpc.ServerStreamingServer[WatchEvent] // ResourceStore_ServiceDesc is the grpc.ServiceDesc for ResourceStore service. // It's only intended for direct use with grpc.RegisterService, @@ -366,10 +319,6 @@ var ResourceStore_ServiceDesc = grpc.ServiceDesc{ MethodName: "Delete", Handler: _ResourceStore_Delete_Handler, }, - { - MethodName: "Restore", - Handler: _ResourceStore_Restore_Handler, - }, { MethodName: "List", Handler: _ResourceStore_List_Handler, @@ -396,7 +345,7 @@ type BulkStoreClient interface { // Write multiple resources to the same Namespace/Group/Resource // Events will not be sent until the stream is complete // Only the *create* permissions is checked - BulkProcess(ctx context.Context, opts ...grpc.CallOption) (BulkStore_BulkProcessClient, error) + BulkProcess(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[BulkRequest, BulkResponse], error) } type bulkStoreClient struct { @@ -407,58 +356,40 @@ func NewBulkStoreClient(cc grpc.ClientConnInterface) BulkStoreClient { return &bulkStoreClient{cc} } -func (c *bulkStoreClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (BulkStore_BulkProcessClient, error) { +func (c *bulkStoreClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[BulkRequest, BulkResponse], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &BulkStore_ServiceDesc.Streams[0], BulkStore_BulkProcess_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &bulkStoreBulkProcessClient{ClientStream: stream} + x := &grpc.GenericClientStream[BulkRequest, BulkResponse]{ClientStream: stream} return x, nil } -type BulkStore_BulkProcessClient interface { - Send(*BulkRequest) error - CloseAndRecv() (*BulkResponse, error) - grpc.ClientStream -} - -type bulkStoreBulkProcessClient struct { - grpc.ClientStream -} - -func (x *bulkStoreBulkProcessClient) Send(m *BulkRequest) error { - return x.ClientStream.SendMsg(m) -} - -func (x *bulkStoreBulkProcessClient) CloseAndRecv() (*BulkResponse, error) { - if err := x.ClientStream.CloseSend(); err != nil { - return nil, err - } - m := new(BulkResponse) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type BulkStore_BulkProcessClient = grpc.ClientStreamingClient[BulkRequest, BulkResponse] // BulkStoreServer is the server API for BulkStore service. // All implementations should embed UnimplementedBulkStoreServer -// for forward compatibility +// for forward compatibility. type BulkStoreServer interface { // Write multiple resources to the same Namespace/Group/Resource // Events will not be sent until the stream is complete // Only the *create* permissions is checked - BulkProcess(BulkStore_BulkProcessServer) error + BulkProcess(grpc.ClientStreamingServer[BulkRequest, BulkResponse]) error } -// UnimplementedBulkStoreServer should be embedded to have forward compatible implementations. -type UnimplementedBulkStoreServer struct { -} +// UnimplementedBulkStoreServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedBulkStoreServer struct{} -func (UnimplementedBulkStoreServer) BulkProcess(BulkStore_BulkProcessServer) error { +func (UnimplementedBulkStoreServer) BulkProcess(grpc.ClientStreamingServer[BulkRequest, BulkResponse]) error { return status.Errorf(codes.Unimplemented, "method BulkProcess not implemented") } +func (UnimplementedBulkStoreServer) testEmbeddedByValue() {} // UnsafeBulkStoreServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to BulkStoreServer will @@ -468,34 +399,22 @@ type UnsafeBulkStoreServer interface { } func RegisterBulkStoreServer(s grpc.ServiceRegistrar, srv BulkStoreServer) { + // If the following call pancis, it indicates UnimplementedBulkStoreServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&BulkStore_ServiceDesc, srv) } func _BulkStore_BulkProcess_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(BulkStoreServer).BulkProcess(&bulkStoreBulkProcessServer{ServerStream: stream}) + return srv.(BulkStoreServer).BulkProcess(&grpc.GenericServerStream[BulkRequest, BulkResponse]{ServerStream: stream}) } -type BulkStore_BulkProcessServer interface { - SendAndClose(*BulkResponse) error - Recv() (*BulkRequest, error) - grpc.ServerStream -} - -type bulkStoreBulkProcessServer struct { - grpc.ServerStream -} - -func (x *bulkStoreBulkProcessServer) SendAndClose(m *BulkResponse) error { - return x.ServerStream.SendMsg(m) -} - -func (x *bulkStoreBulkProcessServer) Recv() (*BulkRequest, error) { - m := new(BulkRequest) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type BulkStore_BulkProcessServer = grpc.ClientStreamingServer[BulkRequest, BulkResponse] // BulkStore_ServiceDesc is the grpc.ServiceDesc for BulkStore service. // It's only intended for direct use with grpc.RegisterService, @@ -561,7 +480,7 @@ func (c *resourceIndexClient) GetStats(ctx context.Context, in *ResourceStatsReq // ResourceIndexServer is the server API for ResourceIndex service. // All implementations should embed UnimplementedResourceIndexServer -// for forward compatibility +// for forward compatibility. // // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics @@ -571,9 +490,12 @@ type ResourceIndexServer interface { GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) } -// UnimplementedResourceIndexServer should be embedded to have forward compatible implementations. -type UnimplementedResourceIndexServer struct { -} +// UnimplementedResourceIndexServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedResourceIndexServer struct{} func (UnimplementedResourceIndexServer) Search(context.Context, *ResourceSearchRequest) (*ResourceSearchResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Search not implemented") @@ -581,6 +503,7 @@ func (UnimplementedResourceIndexServer) Search(context.Context, *ResourceSearchR func (UnimplementedResourceIndexServer) GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented") } +func (UnimplementedResourceIndexServer) testEmbeddedByValue() {} // UnsafeResourceIndexServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ResourceIndexServer will @@ -590,6 +513,13 @@ type UnsafeResourceIndexServer interface { } func RegisterResourceIndexServer(s grpc.ServiceRegistrar, srv ResourceIndexServer) { + // If the following call pancis, it indicates UnimplementedResourceIndexServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&ResourceIndex_ServiceDesc, srv) } @@ -697,7 +627,7 @@ func (c *managedObjectIndexClient) ListManagedObjects(ctx context.Context, in *L // ManagedObjectIndexServer is the server API for ManagedObjectIndex service. // All implementations should embed UnimplementedManagedObjectIndexServer -// for forward compatibility +// for forward compatibility. // // Query managed objects // Results access control is based on access to the repository *not* the items @@ -708,9 +638,12 @@ type ManagedObjectIndexServer interface { ListManagedObjects(context.Context, *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error) } -// UnimplementedManagedObjectIndexServer should be embedded to have forward compatible implementations. -type UnimplementedManagedObjectIndexServer struct { -} +// UnimplementedManagedObjectIndexServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedManagedObjectIndexServer struct{} func (UnimplementedManagedObjectIndexServer) CountManagedObjects(context.Context, *CountManagedObjectsRequest) (*CountManagedObjectsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method CountManagedObjects not implemented") @@ -718,6 +651,7 @@ func (UnimplementedManagedObjectIndexServer) CountManagedObjects(context.Context func (UnimplementedManagedObjectIndexServer) ListManagedObjects(context.Context, *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListManagedObjects not implemented") } +func (UnimplementedManagedObjectIndexServer) testEmbeddedByValue() {} // UnsafeManagedObjectIndexServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ManagedObjectIndexServer will @@ -727,6 +661,13 @@ type UnsafeManagedObjectIndexServer interface { } func RegisterManagedObjectIndexServer(s grpc.ServiceRegistrar, srv ManagedObjectIndexServer) { + // If the following call pancis, it indicates UnimplementedManagedObjectIndexServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&ManagedObjectIndex_ServiceDesc, srv) } @@ -832,7 +773,7 @@ func (c *blobStoreClient) GetBlob(ctx context.Context, in *GetBlobRequest, opts // BlobStoreServer is the server API for BlobStore service. // All implementations should embed UnimplementedBlobStoreServer -// for forward compatibility +// for forward compatibility. type BlobStoreServer interface { // Upload a blob that will be saved in a resource PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) @@ -841,9 +782,12 @@ type BlobStoreServer interface { GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error) } -// UnimplementedBlobStoreServer should be embedded to have forward compatible implementations. -type UnimplementedBlobStoreServer struct { -} +// UnimplementedBlobStoreServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedBlobStoreServer struct{} func (UnimplementedBlobStoreServer) PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method PutBlob not implemented") @@ -851,6 +795,7 @@ func (UnimplementedBlobStoreServer) PutBlob(context.Context, *PutBlobRequest) (* func (UnimplementedBlobStoreServer) GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetBlob not implemented") } +func (UnimplementedBlobStoreServer) testEmbeddedByValue() {} // UnsafeBlobStoreServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to BlobStoreServer will @@ -860,6 +805,13 @@ type UnsafeBlobStoreServer interface { } func RegisterBlobStoreServer(s grpc.ServiceRegistrar, srv BlobStoreServer) { + // If the following call pancis, it indicates UnimplementedBlobStoreServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&BlobStore_ServiceDesc, srv) } @@ -954,7 +906,7 @@ func (c *diagnosticsClient) IsHealthy(ctx context.Context, in *HealthCheckReques // DiagnosticsServer is the server API for Diagnostics service. // All implementations should embed UnimplementedDiagnosticsServer -// for forward compatibility +// for forward compatibility. // // Clients can use this service directly // NOTE: This is read only, and no read afer write guarantees @@ -963,13 +915,17 @@ type DiagnosticsServer interface { IsHealthy(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) } -// UnimplementedDiagnosticsServer should be embedded to have forward compatible implementations. -type UnimplementedDiagnosticsServer struct { -} +// UnimplementedDiagnosticsServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDiagnosticsServer struct{} func (UnimplementedDiagnosticsServer) IsHealthy(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method IsHealthy not implemented") } +func (UnimplementedDiagnosticsServer) testEmbeddedByValue() {} // UnsafeDiagnosticsServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to DiagnosticsServer will @@ -979,6 +935,13 @@ type UnsafeDiagnosticsServer interface { } func RegisterDiagnosticsServer(s grpc.ServiceRegistrar, srv DiagnosticsServer) { + // If the following call pancis, it indicates UnimplementedDiagnosticsServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&Diagnostics_ServiceDesc, srv) } diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 0a983de75aa..9a1b232bb2f 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -10,14 +10,12 @@ import ( "sync/atomic" "time" - "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/types" claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -393,6 +391,7 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour if oldValue == nil { event.Type = WatchEvent_ADDED } else { + event.Type = WatchEvent_MODIFIED check.Verb = utils.VerbUpdate temp := &unstructured.Unstructured{} @@ -404,13 +403,6 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour if err != nil { return nil, AsErrorResult(err) } - - // restores will restore with a different k8s uid - if event.ObjectOld.GetUID() != obj.GetUID() { - event.Type = WatchEvent_ADDED - } else { - event.Type = WatchEvent_MODIFIED - } } if key.Namespace != obj.GetNamespace() { @@ -792,126 +784,6 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err return rsp, err } -func (s *server) Restore(ctx context.Context, req *RestoreRequest) (*RestoreResponse, error) { - ctx, span := s.tracer.Start(ctx, "storage_server.List") - defer span.End() - - // check that the user has access - user, ok := claims.AuthInfoFrom(ctx) - if !ok || user == nil { - return &RestoreResponse{ - Error: &ErrorResult{ - Message: "no user found in context", - Code: http.StatusUnauthorized, - }}, nil - } - - if err := s.Init(ctx); err != nil { - return nil, err - } - - checker, err := s.access.Compile(ctx, user, claims.ListRequest{ - Group: req.Key.Group, - Resource: req.Key.Resource, - Namespace: req.Key.Namespace, - Verb: utils.VerbGet, - }) - if err != nil { - return &RestoreResponse{Error: AsErrorResult(err)}, nil - } - if checker == nil { - return &RestoreResponse{Error: &ErrorResult{ - Code: http.StatusForbidden, - }}, nil - } - - // get the asked for resource version to restore - readRsp, err := s.Read(ctx, &ReadRequest{ - Key: req.Key, - ResourceVersion: req.ResourceVersion, - IncludeDeleted: true, - }) - if err != nil || readRsp == nil || readRsp.Error != nil { - return &RestoreResponse{ - Error: &ErrorResult{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("could not find old resource: %s", readRsp.Error.Message), - }, - }, nil - } - - // generate a new k8s UID when restoring. The name will remain the same - // (for dashboards, this will be the dashboard uid), but since controllers - // will see this as a create event, we do not want the same k8s UID, or - // there may be unintended behavior - newUid := types.UID(uuid.NewString()) - tmp := &unstructured.Unstructured{} - err = tmp.UnmarshalJSON(readRsp.Value) - if err != nil { - return &RestoreResponse{ - Error: &ErrorResult{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("could not unmarhsal: %s", err.Error()), - }, - }, nil - } - obj, err := utils.MetaAccessor(tmp) - if err != nil { - return &RestoreResponse{ - Error: &ErrorResult{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("could not get object: %s", err.Error()), - }, - }, nil - } - obj.SetUID(newUid) - - rtObj, ok := obj.GetRuntimeObject() - if !ok { - return &RestoreResponse{ - Error: &ErrorResult{ - Code: http.StatusNotFound, - Message: "could not get runtime object", - }, - }, nil - } - - newObj, err := json.Marshal(rtObj) - if err != nil { - return &RestoreResponse{ - Error: &ErrorResult{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("could not marshal object: %s", err.Error()), - }, - }, nil - } - - // finally, send to the backend to create & update the history of the restored object - event, errRes := s.newEvent(ctx, user, req.Key, newObj, readRsp.Value) - if errRes != nil { - return &RestoreResponse{ - Error: &ErrorResult{ - Code: http.StatusInternalServerError, - Message: fmt.Sprintf("could not create restore resource event: %s", errRes.Message), - }, - }, nil - } - rv, err := s.backend.WriteEvent(ctx, *event) - if err != nil { - return &RestoreResponse{ - Error: &ErrorResult{ - Code: http.StatusInternalServerError, - Message: fmt.Sprintf("could not restore resource: %s", err.Error()), - }, - }, nil - } - - return &RestoreResponse{ - Error: nil, - ResourceVersion: rv, - }, nil -} - func (s *server) initWatcher() error { var err error s.broadcaster, err = NewBroadcaster(s.ctx, func(out chan<- *WrittenEvent) error { diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index 3e204717040..bb42d73b12b 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -233,79 +233,4 @@ func TestSimpleServer(t *testing.T) { ResourceVersion: created.ResourceVersion}) require.ErrorIs(t, err, ErrOptimisticLockingFailed) }) - - t.Run("playlist restore", func(t *testing.T) { - uid := "zzz" - raw := []byte(`{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": { - "name": "fdgsv37qslr0ga", - "namespace": "default", - "uid": "` + uid + `", - "annotations": { - "grafana.app/repoName": "elsewhere", - "grafana.app/repoPath": "path/to/item", - "grafana.app/repoTimestamp": "2024-02-02T00:00:00Z" - } - }, - "spec": { - "title": "hello", - "interval": "5m", - "items": [ - { - "type": "dashboard_by_uid", - "value": "vmie2cmWz" - } - ] - } - }`) - - key := &ResourceKey{ - Group: "playlist.grafana.app", - Resource: "rrrr", - Namespace: "default", - Name: "fdgsv37qslr0ga", - } - - // create - created, err := server.Create(ctx, &CreateRequest{ - Value: raw, - Key: key, - }) - require.NoError(t, err) - - // make sure it exists - found, err := server.Read(ctx, &ReadRequest{Key: key}) - require.NoError(t, err) - require.Nil(t, found.Error) - fmt.Println(found.ResourceVersion) - - // delete it - deleted, err := server.Delete(ctx, &DeleteRequest{Key: key, ResourceVersion: created.ResourceVersion}) - require.NoError(t, err) - require.True(t, deleted.ResourceVersion > created.ResourceVersion) - - // restore it - restored, err := server.Restore(ctx, &RestoreRequest{ - Key: key, - ResourceVersion: found.ResourceVersion, - }) - require.NoError(t, err) - require.Nil(t, restored.Error) - require.True(t, restored.ResourceVersion > deleted.ResourceVersion) - - // ensure it exists now - found, err = server.Read(ctx, &ReadRequest{Key: key}) - require.NoError(t, err) - require.Nil(t, found.Error) - require.Equal(t, restored.ResourceVersion, found.ResourceVersion) - foundUnstructured := &unstructured.Unstructured{} - err = foundUnstructured.UnmarshalJSON(found.Value) - require.NoError(t, err) - foundObj, err := utils.MetaAccessor(foundUnstructured) - require.NoError(t, err) - // the UID should be different now - require.NotEqual(t, uid, string(foundObj.GetUID())) - }) } diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index c4f2a82e725..0c5fc6a7b40 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -308,9 +308,6 @@ func (b *backend) WriteEvent(ctx context.Context, event resource.WriteEvent) (in // TODO: validate key ? switch event.Type { case resource.WatchEvent_ADDED: - if event.ObjectOld != nil { - return b.restore(ctx, event) - } return b.create(ctx, event) case resource.WatchEvent_MODIFIED: return b.update(ctx, event) @@ -488,73 +485,6 @@ func (b *backend) delete(ctx context.Context, event resource.WriteEvent) (int64, return rv, nil } -func (b *backend) restore(ctx context.Context, event resource.WriteEvent) (int64, error) { - ctx, span := b.tracer.Start(ctx, tracePrefix+"Restore") - defer span.End() - guid := uuid.New().String() - folder := "" - if event.Object != nil { - folder = event.Object.GetFolder() - } - rv, err := b.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) { - // 1. Re-create resource - // Note: we may want to replace the write event with a create event, tbd. - if _, err := dbutil.Exec(ctx, tx, sqlResourceInsert, sqlResourceRequest{ - SQLTemplate: sqltemplate.New(b.dialect), - WriteEvent: event, - Folder: folder, - GUID: guid, - }); err != nil { - return guid, fmt.Errorf("insert into resource: %w", err) - } - - // 2. Insert into resource history - if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryInsert, sqlResourceRequest{ - SQLTemplate: sqltemplate.New(b.dialect), - WriteEvent: event, - Folder: folder, - GUID: guid, - }); err != nil { - return guid, fmt.Errorf("insert into resource history: %w", err) - } - _ = b.historyPruner.Add(pruningKey{ - namespace: event.Key.Namespace, - group: event.Key.Group, - resource: event.Key.Resource, - name: event.Key.Name, - }) - - // 3. Update all resource history entries with the new UID - // Note: we do not update any history entries that have a deletion timestamp included. This will become - // important once we start using finalizers, as the initial delete will show up as an update with a deletion timestamp included. - if _, err := dbutil.Exec(ctx, tx, sqlResoureceHistoryUpdateUid, sqlResourceHistoryUpdateRequest{ - SQLTemplate: sqltemplate.New(b.dialect), - WriteEvent: event, - OldUID: string(event.ObjectOld.GetUID()), - NewUID: string(event.Object.GetUID()), - }); err != nil { - return guid, fmt.Errorf("update history uid: %w", err) - } - - return guid, nil - }) - - if err != nil { - return 0, err - } - - b.notifier.send(ctx, &resource.WrittenEvent{ - Type: event.Type, - Key: event.Key, - PreviousRV: event.PreviousRV, - Value: event.Value, - ResourceVersion: rv, - Folder: folder, - }) - - return rv, nil -} - func (b *backend) ReadResource(ctx context.Context, req *resource.ReadRequest) *resource.BackendReadResponse { _, span := b.tracer.Start(ctx, tracePrefix+".Read") defer span.End() @@ -577,17 +507,6 @@ func (b *backend) ReadResource(ctx context.Context, req *resource.ReadRequest) * err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error { var err error res, err = dbutil.QueryRow(ctx, tx, sr, readReq) - // if not found, look for latest deleted version (if requested) - if errors.Is(err, sql.ErrNoRows) && req.IncludeDeleted { - sr = sqlResourceHistoryRead - readReq2 := &sqlResourceReadRequest{ - SQLTemplate: sqltemplate.New(b.dialect), - Request: req, - Response: NewReadResponse(), - } - res, err = dbutil.QueryRow(ctx, tx, sr, readReq2) - return err - } return err }) diff --git a/pkg/storage/unified/sql/backend_test.go b/pkg/storage/unified/sql/backend_test.go index 24b9317c568..27c8e98854c 100644 --- a/pkg/storage/unified/sql/backend_test.go +++ b/pkg/storage/unified/sql/backend_test.go @@ -376,88 +376,6 @@ func TestBackend_delete(t *testing.T) { }) } -func TestBackend_restore(t *testing.T) { - t.Parallel() - meta, err := utils.MetaAccessor(&unstructured.Unstructured{ - Object: map[string]any{}, - }) - require.NoError(t, err) - meta.SetUID("new-uid") - oldMeta, err := utils.MetaAccessor(&unstructured.Unstructured{ - Object: map[string]any{}, - }) - require.NoError(t, err) - oldMeta.SetUID("old-uid") - event := resource.WriteEvent{ - Type: resource.WatchEvent_ADDED, - Key: resKey, - Object: meta, - ObjectOld: oldMeta, - } - - t.Run("happy path", func(t *testing.T) { - t.Parallel() - b, ctx := setupBackendTest(t) - - b.SQLMock.ExpectBegin() - expectSuccessfulResourceVersionExec(t, b.TestDBProvider, - func() { b.ExecWithResult("insert resource", 0, 1) }, - func() { b.ExecWithResult("insert resource_history", 0, 1) }, - func() { b.ExecWithResult("update resource_history", 0, 1) }, - ) - b.SQLMock.ExpectCommit() - - v, err := b.restore(ctx, event) - require.NoError(t, err) - require.Equal(t, int64(200), v) - }) - - t.Run("error restoring resource", func(t *testing.T) { - t.Parallel() - b, ctx := setupBackendTest(t) - - b.SQLMock.ExpectBegin() - b.ExecWithErr("insert resource", errTest) - b.SQLMock.ExpectRollback() - - v, err := b.restore(ctx, event) - require.Zero(t, v) - require.Error(t, err) - require.ErrorContains(t, err, "insert into resource") - }) - - t.Run("error inserting into resource history", func(t *testing.T) { - t.Parallel() - b, ctx := setupBackendTest(t) - - b.SQLMock.ExpectBegin() - b.ExecWithResult("insert resource", 0, 1) - b.ExecWithErr("insert resource_history", errTest) - b.SQLMock.ExpectRollback() - - v, err := b.restore(ctx, event) - require.Zero(t, v) - require.Error(t, err) - require.ErrorContains(t, err, "insert into resource history") - }) - - t.Run("error updating resource history uid", func(t *testing.T) { - t.Parallel() - b, ctx := setupBackendTest(t) - - b.SQLMock.ExpectBegin() - b.ExecWithResult("insert resource", 0, 1) - b.ExecWithResult("insert resource_history", 0, 1) - b.ExecWithErr("update resource_history", errTest) - b.SQLMock.ExpectRollback() - - v, err := b.restore(ctx, event) - require.Zero(t, v) - require.Error(t, err) - require.ErrorContains(t, err, "update history uid") - }) -} - func TestBackend_getHistory(t *testing.T) { t.Parallel() diff --git a/pkg/storage/unified/sql/data/resource_history_read.sql b/pkg/storage/unified/sql/data/resource_history_read.sql index d78209817b2..3eb350e275d 100644 --- a/pkg/storage/unified/sql/data/resource_history_read.sql +++ b/pkg/storage/unified/sql/data/resource_history_read.sql @@ -14,12 +14,8 @@ SELECT AND {{ .Ident "group" }} = {{ .Arg .Request.Key.Group }} AND {{ .Ident "resource" }} = {{ .Arg .Request.Key.Resource }} AND {{ .Ident "name" }} = {{ .Arg .Request.Key.Name }} - {{ if .Request.IncludeDeleted }} - AND {{ .Ident "action" }} != 3 - AND {{ .Ident "value" }} NOT LIKE '%deletionTimestamp%' - {{ end }} {{ if gt .Request.ResourceVersion 0 }} - AND {{ .Ident "resource_version" }} {{ if .Request.IncludeDeleted }}={{ else }}<={{ end }} {{ .Arg .Request.ResourceVersion }} + AND {{ .Ident "resource_version" }} <= {{ .Arg .Request.ResourceVersion }} {{ end }} ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 diff --git a/pkg/storage/unified/sql/data/resource_history_update_uid.sql b/pkg/storage/unified/sql/data/resource_history_update_uid.sql deleted file mode 100644 index 7624b5e9c53..00000000000 --- a/pkg/storage/unified/sql/data/resource_history_update_uid.sql +++ /dev/null @@ -1,8 +0,0 @@ -UPDATE {{ .Ident "resource_history" }} - SET {{ .Ident "value" }} = REPLACE({{ .Ident "value" }}, CONCAT('"uid":"', {{ .Arg .OldUID }}, '"'), CONCAT('"uid":"', {{ .Arg .NewUID }}, '"')) - WHERE {{ .Ident "name" }} = {{ .Arg .WriteEvent.Key.Name }} - AND {{ .Ident "namespace" }} = {{ .Arg .WriteEvent.Key.Namespace }} - AND {{ .Ident "group" }} = {{ .Arg .WriteEvent.Key.Group }} - AND {{ .Ident "resource" }} = {{ .Arg .WriteEvent.Key.Resource }} - AND {{ .Ident "action" }} != 3 - AND {{ .Ident "value" }} NOT LIKE '%deletionTimestamp%'; diff --git a/pkg/storage/unified/sql/queries.go b/pkg/storage/unified/sql/queries.go index 022c33f4775..0e68f1b6873 100644 --- a/pkg/storage/unified/sql/queries.go +++ b/pkg/storage/unified/sql/queries.go @@ -39,7 +39,6 @@ var ( sqlResourceUpdateRV = mustTemplate("resource_update_rv.sql") sqlResourceHistoryRead = mustTemplate("resource_history_read.sql") sqlResourceHistoryUpdateRV = mustTemplate("resource_history_update_rv.sql") - sqlResoureceHistoryUpdateUid = mustTemplate("resource_history_update_uid.sql") sqlResourceHistoryInsert = mustTemplate("resource_history_insert.sql") sqlResourceHistoryPoll = mustTemplate("resource_history_poll.sql") sqlResourceHistoryGet = mustTemplate("resource_history_get.sql") @@ -281,19 +280,6 @@ func (r *sqlPruneHistoryRequest) Validate() error { return nil } -// update resource history - -type sqlResourceHistoryUpdateRequest struct { - sqltemplate.SQLTemplate - WriteEvent resource.WriteEvent - OldUID string - NewUID string -} - -func (r sqlResourceHistoryUpdateRequest) Validate() error { - return nil // TODO -} - type sqlResourceBlobInsertRequest struct { sqltemplate.SQLTemplate Now time.Time diff --git a/pkg/storage/unified/sql/queries_test.go b/pkg/storage/unified/sql/queries_test.go index 9858394bccc..6321604fbe4 100644 --- a/pkg/storage/unified/sql/queries_test.go +++ b/pkg/storage/unified/sql/queries_test.go @@ -176,26 +176,6 @@ func TestUnifiedStorageQueries(t *testing.T) { }, }, - sqlResoureceHistoryUpdateUid: { - { - Name: "modify uids in history", - Data: &sqlResourceHistoryUpdateRequest{ - SQLTemplate: mocks.NewTestingSQLTemplate(), - WriteEvent: resource.WriteEvent{ - Key: &resource.ResourceKey{ - Namespace: "nn", - Group: "gg", - Resource: "rr", - Name: "name", - }, - PreviousRV: 1234, - }, - OldUID: "old-uid", - NewUID: "new-uid", - }, - }, - }, - sqlResourceHistoryInsert: { { Name: "insert into resource_history", diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_history_update_uid-modify uids in history.sql b/pkg/storage/unified/sql/testdata/mysql--resource_history_update_uid-modify uids in history.sql deleted file mode 100755 index 05431eea218..00000000000 --- a/pkg/storage/unified/sql/testdata/mysql--resource_history_update_uid-modify uids in history.sql +++ /dev/null @@ -1,8 +0,0 @@ -UPDATE `resource_history` - SET `value` = REPLACE(`value`, CONCAT('"uid":"', 'old-uid', '"'), CONCAT('"uid":"', 'new-uid', '"')) - WHERE `name` = 'name' - AND `namespace` = 'nn' - AND `group` = 'gg' - AND `resource` = 'rr' - AND `action` != 3 - AND `value` NOT LIKE '%deletionTimestamp%'; diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_history_update_uid-modify uids in history.sql b/pkg/storage/unified/sql/testdata/postgres--resource_history_update_uid-modify uids in history.sql deleted file mode 100755 index 79192fa5a6e..00000000000 --- a/pkg/storage/unified/sql/testdata/postgres--resource_history_update_uid-modify uids in history.sql +++ /dev/null @@ -1,8 +0,0 @@ -UPDATE "resource_history" - SET "value" = REPLACE("value", CONCAT('"uid":"', 'old-uid', '"'), CONCAT('"uid":"', 'new-uid', '"')) - WHERE "name" = 'name' - AND "namespace" = 'nn' - AND "group" = 'gg' - AND "resource" = 'rr' - AND "action" != 3 - AND "value" NOT LIKE '%deletionTimestamp%'; diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_uid-modify uids in history.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_uid-modify uids in history.sql deleted file mode 100755 index 79192fa5a6e..00000000000 --- a/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_uid-modify uids in history.sql +++ /dev/null @@ -1,8 +0,0 @@ -UPDATE "resource_history" - SET "value" = REPLACE("value", CONCAT('"uid":"', 'old-uid', '"'), CONCAT('"uid":"', 'new-uid', '"')) - WHERE "name" = 'name' - AND "namespace" = 'nn' - AND "group" = 'gg' - AND "resource" = 'rr' - AND "action" != 3 - AND "value" NOT LIKE '%deletionTimestamp%'; From 1067bf9025dd4ca41b485e31b550fac84f0a8acc Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Thu, 20 Mar 2025 22:50:18 +0100 Subject: [PATCH 22/79] Loki: Remove range options from query builder (#102566) * Loki: Remove range options from query builder * Loki: Improve description * Loki: Fix test * Loki: Add default `$__auto` * Loki: Fix test --- .../datasource/loki/components/LokiQueryEditor.test.tsx | 6 +++--- .../plugins/datasource/loki/querybuilder/operationUtils.ts | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx index a821cdf8c38..9ab6f7cc667 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx @@ -151,7 +151,7 @@ describe('LokiQueryEditorSelector', () => { it('parses query when changing to builder mode', async () => { const { rerender } = renderWithProps({ refId: 'A', - expr: 'rate({instance="host.docker.internal:3000"}[$__interval])', + expr: 'rate({instance="host.docker.internal:3000"}[$__auto])', editorMode: QueryEditorMode.Code, }); await expectCodeEditor(); @@ -161,7 +161,7 @@ describe('LokiQueryEditorSelector', () => { {...defaultProps} query={{ refId: 'A', - expr: 'rate({instance="host.docker.internal:3000"}[$__interval])', + expr: 'rate({instance="host.docker.internal:3000"}[$__auto])', editorMode: QueryEditorMode.Builder, }} /> @@ -169,7 +169,7 @@ describe('LokiQueryEditorSelector', () => { await screen.findByText('host.docker.internal:3000'); expect(screen.getByText('Rate')).toBeInTheDocument(); - expect(screen.getByText('$__interval')).toBeInTheDocument(); + expect(screen.getByText('$__auto')).toBeInTheDocument(); }); it('renders the label browser button', async () => { diff --git a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts index a3eb6702008..416f0e4c5fd 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts @@ -336,11 +336,14 @@ export function getLineFilterRenderer(operation: string, caseInsensitive?: boole return `${innerExpr} ${operation} ${delimiter}${params.join(`${delimiter} or ${delimiter}`)}${delimiter}`; }; } + function getRangeVectorParamDef(): QueryBuilderOperationParamDef { return { name: 'Range', type: 'string', - options: ['$__auto', '1m', '5m', '10m', '1h', '24h'], + options: ['$__auto'], + description: + 'Use the default value "$__auto". Change the "step" value in the query options to change the bucket size.', }; } From ac90e314a6cec77b144400ebb0f4f1e1c2d6ced8 Mon Sep 17 00:00:00 2001 From: Jev Forsberg <46619047+baldm0mma@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:24:36 -0600 Subject: [PATCH 23/79] Chore: Add pushes to `main` in the `backend-unit-test` workflow (#102570) * baldm0mma/ update backend unit tests to run on push to `main` * baldm0mma/ update naming --- .github/CODEOWNERS | 2 +- .../{pr-backend-unit-tests.yml => backend-unit-tests.yml} | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) rename .github/workflows/{pr-backend-unit-tests.yml => backend-unit-tests.yml} (96%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bdf666d3fc6..247bc556cdb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -768,6 +768,7 @@ embed.go @grafana/grafana-as-code /.github/workflows/alerting-swagger-gen.yml @grafana/alerting-backend /.github/workflows/alerting-update-module.yml @grafana/alerting-backend /.github/workflows/auto-milestone.yml @grafana/grafana-developer-enablement-squad +/.github/workflows/backend-unit-tests.yml @grafana/grafana-backend-group /.github/workflows/backport.yml @grafana/grafana-developer-enablement-squad /.github/workflows/bump-version.yml @grafana/grafana-developer-enablement-squad /.github/workflows/close-milestone.yml @grafana/grafana-developer-enablement-squad @@ -798,7 +799,6 @@ embed.go @grafana/grafana-as-code /.github/workflows/pr-lint-build-docs.yml @grafana/docs-tooling /.github/workflows/pr-patch-check.yml @grafana/grafana-developer-enablement-squad /.github/workflows/pr-test-integration.yml @grafana/grafana-backend-group -/.github/workflows/pr-backend-unit-tests.yml @grafana/grafana-backend-group /.github/workflows/pr-backend-coverage.yml @grafana/grafana-backend-group /.github/workflows/sync-mirror.yml @grafana/grafana-developer-enablement-squad /.github/workflows/publish-technical-documentation-next.yml @grafana/docs-tooling diff --git a/.github/workflows/pr-backend-unit-tests.yml b/.github/workflows/backend-unit-tests.yml similarity index 96% rename from .github/workflows/pr-backend-unit-tests.yml rename to .github/workflows/backend-unit-tests.yml index f81b9c8f6ac..e9412ca9a67 100644 --- a/.github/workflows/pr-backend-unit-tests.yml +++ b/.github/workflows/backend-unit-tests.yml @@ -5,6 +5,13 @@ on: paths-ignore: - 'docs/**' - '**/*.md' + push: + branches: + - main + - release-*.*.* + paths-ignore: + - 'docs/**' + - '**/*.md' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 1483dee75c1043bf00088bc8a1af7eecabce71e7 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Thu, 20 Mar 2025 16:51:03 -0600 Subject: [PATCH 24/79] Unistore: undo protobuf version change (#102572) --- .../unified/resource/resource_grpc.pb.go | 225 +++++++++--------- 1 file changed, 112 insertions(+), 113 deletions(-) diff --git a/pkg/storage/unified/resource/resource_grpc.pb.go b/pkg/storage/unified/resource/resource_grpc.pb.go index 950788f001e..4186147ef7c 100644 --- a/pkg/storage/unified/resource/resource_grpc.pb.go +++ b/pkg/storage/unified/resource/resource_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 +// - protoc-gen-go-grpc v1.4.0 // - protoc (unknown) // source: resource.proto @@ -15,8 +15,8 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 +// Requires gRPC-Go v1.62.0 or later. +const _ = grpc.SupportPackageIsVersion8 const ( ResourceStore_Read_FullMethodName = "/resource.ResourceStore/Read" @@ -47,7 +47,7 @@ type ResourceStoreClient interface { // The results *may* include values that should not be returned to the user // This will perform best-effort filtering to increase performace. // NOTE: storage.Interface is ultimatly responsible for the final filtering - Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[WatchEvent], error) + Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (ResourceStore_WatchClient, error) } type resourceStoreClient struct { @@ -108,13 +108,13 @@ func (c *resourceStoreClient) List(ctx context.Context, in *ListRequest, opts .. return out, nil } -func (c *resourceStoreClient) Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[WatchEvent], error) { +func (c *resourceStoreClient) Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (ResourceStore_WatchClient, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &ResourceStore_ServiceDesc.Streams[0], ResourceStore_Watch_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &grpc.GenericClientStream[WatchRequest, WatchEvent]{ClientStream: stream} + x := &resourceStoreWatchClient{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -124,12 +124,26 @@ func (c *resourceStoreClient) Watch(ctx context.Context, in *WatchRequest, opts return x, nil } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ResourceStore_WatchClient = grpc.ServerStreamingClient[WatchEvent] +type ResourceStore_WatchClient interface { + Recv() (*WatchEvent, error) + grpc.ClientStream +} + +type resourceStoreWatchClient struct { + grpc.ClientStream +} + +func (x *resourceStoreWatchClient) Recv() (*WatchEvent, error) { + m := new(WatchEvent) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} // ResourceStoreServer is the server API for ResourceStore service. // All implementations should embed UnimplementedResourceStoreServer -// for forward compatibility. +// for forward compatibility // // This provides the CRUD+List+Watch support needed for a k8s apiserver // The semantics and behaviors of this service are constrained by kubernetes @@ -147,15 +161,12 @@ type ResourceStoreServer interface { // The results *may* include values that should not be returned to the user // This will perform best-effort filtering to increase performace. // NOTE: storage.Interface is ultimatly responsible for the final filtering - Watch(*WatchRequest, grpc.ServerStreamingServer[WatchEvent]) error + Watch(*WatchRequest, ResourceStore_WatchServer) error } -// UnimplementedResourceStoreServer should be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedResourceStoreServer struct{} +// UnimplementedResourceStoreServer should be embedded to have forward compatible implementations. +type UnimplementedResourceStoreServer struct { +} func (UnimplementedResourceStoreServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Read not implemented") @@ -172,10 +183,9 @@ func (UnimplementedResourceStoreServer) Delete(context.Context, *DeleteRequest) func (UnimplementedResourceStoreServer) List(context.Context, *ListRequest) (*ListResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method List not implemented") } -func (UnimplementedResourceStoreServer) Watch(*WatchRequest, grpc.ServerStreamingServer[WatchEvent]) error { +func (UnimplementedResourceStoreServer) Watch(*WatchRequest, ResourceStore_WatchServer) error { return status.Errorf(codes.Unimplemented, "method Watch not implemented") } -func (UnimplementedResourceStoreServer) testEmbeddedByValue() {} // UnsafeResourceStoreServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ResourceStoreServer will @@ -185,13 +195,6 @@ type UnsafeResourceStoreServer interface { } func RegisterResourceStoreServer(s grpc.ServiceRegistrar, srv ResourceStoreServer) { - // If the following call pancis, it indicates UnimplementedResourceStoreServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } s.RegisterService(&ResourceStore_ServiceDesc, srv) } @@ -290,11 +293,21 @@ func _ResourceStore_Watch_Handler(srv interface{}, stream grpc.ServerStream) err if err := stream.RecvMsg(m); err != nil { return err } - return srv.(ResourceStoreServer).Watch(m, &grpc.GenericServerStream[WatchRequest, WatchEvent]{ServerStream: stream}) + return srv.(ResourceStoreServer).Watch(m, &resourceStoreWatchServer{ServerStream: stream}) } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ResourceStore_WatchServer = grpc.ServerStreamingServer[WatchEvent] +type ResourceStore_WatchServer interface { + Send(*WatchEvent) error + grpc.ServerStream +} + +type resourceStoreWatchServer struct { + grpc.ServerStream +} + +func (x *resourceStoreWatchServer) Send(m *WatchEvent) error { + return x.ServerStream.SendMsg(m) +} // ResourceStore_ServiceDesc is the grpc.ServiceDesc for ResourceStore service. // It's only intended for direct use with grpc.RegisterService, @@ -345,7 +358,7 @@ type BulkStoreClient interface { // Write multiple resources to the same Namespace/Group/Resource // Events will not be sent until the stream is complete // Only the *create* permissions is checked - BulkProcess(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[BulkRequest, BulkResponse], error) + BulkProcess(ctx context.Context, opts ...grpc.CallOption) (BulkStore_BulkProcessClient, error) } type bulkStoreClient struct { @@ -356,40 +369,58 @@ func NewBulkStoreClient(cc grpc.ClientConnInterface) BulkStoreClient { return &bulkStoreClient{cc} } -func (c *bulkStoreClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[BulkRequest, BulkResponse], error) { +func (c *bulkStoreClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (BulkStore_BulkProcessClient, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &BulkStore_ServiceDesc.Streams[0], BulkStore_BulkProcess_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &grpc.GenericClientStream[BulkRequest, BulkResponse]{ClientStream: stream} + x := &bulkStoreBulkProcessClient{ClientStream: stream} return x, nil } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type BulkStore_BulkProcessClient = grpc.ClientStreamingClient[BulkRequest, BulkResponse] +type BulkStore_BulkProcessClient interface { + Send(*BulkRequest) error + CloseAndRecv() (*BulkResponse, error) + grpc.ClientStream +} + +type bulkStoreBulkProcessClient struct { + grpc.ClientStream +} + +func (x *bulkStoreBulkProcessClient) Send(m *BulkRequest) error { + return x.ClientStream.SendMsg(m) +} + +func (x *bulkStoreBulkProcessClient) CloseAndRecv() (*BulkResponse, error) { + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + m := new(BulkResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} // BulkStoreServer is the server API for BulkStore service. // All implementations should embed UnimplementedBulkStoreServer -// for forward compatibility. +// for forward compatibility type BulkStoreServer interface { // Write multiple resources to the same Namespace/Group/Resource // Events will not be sent until the stream is complete // Only the *create* permissions is checked - BulkProcess(grpc.ClientStreamingServer[BulkRequest, BulkResponse]) error + BulkProcess(BulkStore_BulkProcessServer) error } -// UnimplementedBulkStoreServer should be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedBulkStoreServer struct{} +// UnimplementedBulkStoreServer should be embedded to have forward compatible implementations. +type UnimplementedBulkStoreServer struct { +} -func (UnimplementedBulkStoreServer) BulkProcess(grpc.ClientStreamingServer[BulkRequest, BulkResponse]) error { +func (UnimplementedBulkStoreServer) BulkProcess(BulkStore_BulkProcessServer) error { return status.Errorf(codes.Unimplemented, "method BulkProcess not implemented") } -func (UnimplementedBulkStoreServer) testEmbeddedByValue() {} // UnsafeBulkStoreServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to BulkStoreServer will @@ -399,22 +430,34 @@ type UnsafeBulkStoreServer interface { } func RegisterBulkStoreServer(s grpc.ServiceRegistrar, srv BulkStoreServer) { - // If the following call pancis, it indicates UnimplementedBulkStoreServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } s.RegisterService(&BulkStore_ServiceDesc, srv) } func _BulkStore_BulkProcess_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(BulkStoreServer).BulkProcess(&grpc.GenericServerStream[BulkRequest, BulkResponse]{ServerStream: stream}) + return srv.(BulkStoreServer).BulkProcess(&bulkStoreBulkProcessServer{ServerStream: stream}) } -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type BulkStore_BulkProcessServer = grpc.ClientStreamingServer[BulkRequest, BulkResponse] +type BulkStore_BulkProcessServer interface { + SendAndClose(*BulkResponse) error + Recv() (*BulkRequest, error) + grpc.ServerStream +} + +type bulkStoreBulkProcessServer struct { + grpc.ServerStream +} + +func (x *bulkStoreBulkProcessServer) SendAndClose(m *BulkResponse) error { + return x.ServerStream.SendMsg(m) +} + +func (x *bulkStoreBulkProcessServer) Recv() (*BulkRequest, error) { + m := new(BulkRequest) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} // BulkStore_ServiceDesc is the grpc.ServiceDesc for BulkStore service. // It's only intended for direct use with grpc.RegisterService, @@ -480,7 +523,7 @@ func (c *resourceIndexClient) GetStats(ctx context.Context, in *ResourceStatsReq // ResourceIndexServer is the server API for ResourceIndex service. // All implementations should embed UnimplementedResourceIndexServer -// for forward compatibility. +// for forward compatibility // // Unlike the ResourceStore, this service can be exposed to clients directly // It should be implemented with efficient indexes and does not need read-after-write semantics @@ -490,12 +533,9 @@ type ResourceIndexServer interface { GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) } -// UnimplementedResourceIndexServer should be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedResourceIndexServer struct{} +// UnimplementedResourceIndexServer should be embedded to have forward compatible implementations. +type UnimplementedResourceIndexServer struct { +} func (UnimplementedResourceIndexServer) Search(context.Context, *ResourceSearchRequest) (*ResourceSearchResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Search not implemented") @@ -503,7 +543,6 @@ func (UnimplementedResourceIndexServer) Search(context.Context, *ResourceSearchR func (UnimplementedResourceIndexServer) GetStats(context.Context, *ResourceStatsRequest) (*ResourceStatsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented") } -func (UnimplementedResourceIndexServer) testEmbeddedByValue() {} // UnsafeResourceIndexServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ResourceIndexServer will @@ -513,13 +552,6 @@ type UnsafeResourceIndexServer interface { } func RegisterResourceIndexServer(s grpc.ServiceRegistrar, srv ResourceIndexServer) { - // If the following call pancis, it indicates UnimplementedResourceIndexServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } s.RegisterService(&ResourceIndex_ServiceDesc, srv) } @@ -627,7 +659,7 @@ func (c *managedObjectIndexClient) ListManagedObjects(ctx context.Context, in *L // ManagedObjectIndexServer is the server API for ManagedObjectIndex service. // All implementations should embed UnimplementedManagedObjectIndexServer -// for forward compatibility. +// for forward compatibility // // Query managed objects // Results access control is based on access to the repository *not* the items @@ -638,12 +670,9 @@ type ManagedObjectIndexServer interface { ListManagedObjects(context.Context, *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error) } -// UnimplementedManagedObjectIndexServer should be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedManagedObjectIndexServer struct{} +// UnimplementedManagedObjectIndexServer should be embedded to have forward compatible implementations. +type UnimplementedManagedObjectIndexServer struct { +} func (UnimplementedManagedObjectIndexServer) CountManagedObjects(context.Context, *CountManagedObjectsRequest) (*CountManagedObjectsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method CountManagedObjects not implemented") @@ -651,7 +680,6 @@ func (UnimplementedManagedObjectIndexServer) CountManagedObjects(context.Context func (UnimplementedManagedObjectIndexServer) ListManagedObjects(context.Context, *ListManagedObjectsRequest) (*ListManagedObjectsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListManagedObjects not implemented") } -func (UnimplementedManagedObjectIndexServer) testEmbeddedByValue() {} // UnsafeManagedObjectIndexServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ManagedObjectIndexServer will @@ -661,13 +689,6 @@ type UnsafeManagedObjectIndexServer interface { } func RegisterManagedObjectIndexServer(s grpc.ServiceRegistrar, srv ManagedObjectIndexServer) { - // If the following call pancis, it indicates UnimplementedManagedObjectIndexServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } s.RegisterService(&ManagedObjectIndex_ServiceDesc, srv) } @@ -773,7 +794,7 @@ func (c *blobStoreClient) GetBlob(ctx context.Context, in *GetBlobRequest, opts // BlobStoreServer is the server API for BlobStore service. // All implementations should embed UnimplementedBlobStoreServer -// for forward compatibility. +// for forward compatibility type BlobStoreServer interface { // Upload a blob that will be saved in a resource PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) @@ -782,12 +803,9 @@ type BlobStoreServer interface { GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error) } -// UnimplementedBlobStoreServer should be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedBlobStoreServer struct{} +// UnimplementedBlobStoreServer should be embedded to have forward compatible implementations. +type UnimplementedBlobStoreServer struct { +} func (UnimplementedBlobStoreServer) PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method PutBlob not implemented") @@ -795,7 +813,6 @@ func (UnimplementedBlobStoreServer) PutBlob(context.Context, *PutBlobRequest) (* func (UnimplementedBlobStoreServer) GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetBlob not implemented") } -func (UnimplementedBlobStoreServer) testEmbeddedByValue() {} // UnsafeBlobStoreServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to BlobStoreServer will @@ -805,13 +822,6 @@ type UnsafeBlobStoreServer interface { } func RegisterBlobStoreServer(s grpc.ServiceRegistrar, srv BlobStoreServer) { - // If the following call pancis, it indicates UnimplementedBlobStoreServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } s.RegisterService(&BlobStore_ServiceDesc, srv) } @@ -906,7 +916,7 @@ func (c *diagnosticsClient) IsHealthy(ctx context.Context, in *HealthCheckReques // DiagnosticsServer is the server API for Diagnostics service. // All implementations should embed UnimplementedDiagnosticsServer -// for forward compatibility. +// for forward compatibility // // Clients can use this service directly // NOTE: This is read only, and no read afer write guarantees @@ -915,17 +925,13 @@ type DiagnosticsServer interface { IsHealthy(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) } -// UnimplementedDiagnosticsServer should be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedDiagnosticsServer struct{} +// UnimplementedDiagnosticsServer should be embedded to have forward compatible implementations. +type UnimplementedDiagnosticsServer struct { +} func (UnimplementedDiagnosticsServer) IsHealthy(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method IsHealthy not implemented") } -func (UnimplementedDiagnosticsServer) testEmbeddedByValue() {} // UnsafeDiagnosticsServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to DiagnosticsServer will @@ -935,13 +941,6 @@ type UnsafeDiagnosticsServer interface { } func RegisterDiagnosticsServer(s grpc.ServiceRegistrar, srv DiagnosticsServer) { - // If the following call pancis, it indicates UnimplementedDiagnosticsServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } s.RegisterService(&Diagnostics_ServiceDesc, srv) } From 4e27ee2ff6964f1ac2158bf7b762b6b311fd1931 Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Thu, 20 Mar 2025 16:51:37 -0600 Subject: [PATCH 25/79] Unified Storage: Add index on dashboard_tag table for dashboard_uid column (#102551) add index on dashboard_tag table for dashboard_uid column --- .../sqlstore/migrations/dashboard_mig.go | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 002b1e2e8a7..c6943ffb07d 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -261,6 +261,11 @@ func addDashboardMigration(mg *Migrator) { mg.AddMigration("Add apiVersion for dashboard", NewAddColumnMigration(dashboardV2, &Column{ Name: "api_version", Type: DB_Varchar, Length: 16, Nullable: true, })) + + mg.AddMigration("Add index for dashboard_uid on dashboard_tag table", NewAddIndexMigration(dashboardTagV1, &Index{ + Cols: []string{"dashboard_uid"}, + Type: IndexType, + })) } type FillDashbordUIDAndOrgIDMigration struct { @@ -278,23 +283,23 @@ func (m *FillDashbordUIDAndOrgIDMigration) Exec(sess *xorm.Session, mg *Migrator func RunDashboardTagMigrations(sess *xorm.Session, driverName string) error { // sqlite sql := `UPDATE dashboard_tag - SET + SET dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = dashboard_tag.dashboard_id), org_id = (SELECT org_id FROM dashboard WHERE dashboard.id = dashboard_tag.dashboard_id) - WHERE + WHERE (dashboard_uid IS NULL OR org_id IS NULL) AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = dashboard_tag.dashboard_id);` if driverName == Postgres { - sql = `UPDATE dashboard_tag - SET dashboard_uid = dashboard.uid, + sql = `UPDATE dashboard_tag + SET dashboard_uid = dashboard.uid, org_id = dashboard.org_id - FROM dashboard + FROM dashboard WHERE dashboard_tag.dashboard_id = dashboard.id AND (dashboard_tag.dashboard_uid IS NULL OR dashboard_tag.org_id IS NULL);` } else if driverName == MySQL { - sql = `UPDATE dashboard_tag - LEFT JOIN dashboard ON dashboard_tag.dashboard_id = dashboard.id - SET dashboard_tag.dashboard_uid = dashboard.uid, + sql = `UPDATE dashboard_tag + LEFT JOIN dashboard ON dashboard_tag.dashboard_id = dashboard.id + SET dashboard_tag.dashboard_uid = dashboard.uid, dashboard_tag.org_id = dashboard.org_id WHERE dashboard_tag.dashboard_uid IS NULL OR dashboard_tag.org_id IS NULL;` } From a5665c06cf96abe0e782f89dbe940ce95cde69d4 Mon Sep 17 00:00:00 2001 From: Alex Bikfalvi Date: Thu, 20 Mar 2025 19:14:57 -0400 Subject: [PATCH 26/79] docs: Span details includes events and links (#102520) * docs: Span details includes events and links Adds to the explore trace integration information about trace span events and links, which are also available in the trace view. Signed-off-by: Alex Bikfalvi * Update trace-integration.md * Apply suggestions from code review * Fix prettier issues Signed-off-by: Alex Bikfalvi --------- Signed-off-by: Alex Bikfalvi Co-authored-by: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> --- docs/sources/explore/trace-integration.md | 24 ++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/sources/explore/trace-integration.md b/docs/sources/explore/trace-integration.md index 532bb3ca8f2..ecfa1331199 100644 --- a/docs/sources/explore/trace-integration.md +++ b/docs/sources/explore/trace-integration.md @@ -85,15 +85,29 @@ You can expand any span in a trace and view the details, including the span and For more information about spans and traces, refer to [Introduction to tracing](https://grafana.com/docs/tempo/latest/introduction/) in the Tempo documentation. -Span details include: +Span details include span attributes, resource attributes, events, and links. -- **Span attributes** - Key/value pairs that provides context for spans. For example, if the span deals with calling another service via HTTP, an attribute could include the HTTP URL (maybe as the span attribute key `http.url`) and the HTTP status code returned (as the span attribute `http.status_code`). +#### Span and resource attributes -- **Resource attributes** - Key/value pairs that describe the context of how the span was collected. +**Span attributes** are key-value pairs that provide metadata about a specific span. They give context to the operation being performed, such as information about the request, response, or any relevant operational details. For example, if the span deals with calling another service via HTTP, an attribute could include the HTTP URL (maybe as the span attribute key `http.url`) and the HTTP status code returned (as the span attribute `http.status_code`). -Refer to [Span and resource attributes](/docs/tempo//operations/best-practices/#span-and-resource-attributes) for more detail. +{{< figure src="/media/docs/tempo/screenshot-grafana-trace-view-span-span-attributes.png" class="docs-image--no-shadow" max-width= "900px" caption="Trace view span attributes" >}} -{{< figure src="/media/docs/tempo/screenshot-grafana-trace-view-span-details.png" class="docs-image--no-shadow" max-width= "900px" caption="Trace view span details" >}} +**Resource attributes** are key-value pairs that describe the environment or entity that is producing the trace. They capture static information about the origin of traces, like the application name or the service version. + +{{< figure src="/media/docs/tempo/screenshot-grafana-trace-view-span-resource-attributes.png" class="docs-image--no-shadow" max-width= "900px" caption="Trace view span resource attributes" >}} + +Span attributes are specific to a particular operation, while resource attributes are associated with the whole trace or the entire service emitting the spans. Refer to [Span and resource attributes](/docs/tempo//operations/best-practices/#span-and-resource-attributes) for more detail. + +#### Events + +Events are log-like records attached to a span that represent an occurrence during its execution. They record notable moments or occurrences within the span's lifecycle, such as errors, warnings, or checkpoints. If an error occurs during an operation, an event can be added to the span to indicate what went wrong and when. Events include a timestamp, name, and key-value pairs attributes that provide additional context or details about the event. + +{{< figure src="/media/docs/tempo/screenshot-grafana-trace-view-span-events.png" class="docs-image--no-shadow" max-width= "900px" caption="Trace view span events" >}} + +#### Links + +Links show relationships between spans that are not in a direct parent-child relationship. They represent associations between spans that happen concurrently or across separate trace trees, linking traces that originated from separate sources but are logically connected, such as background job processing initiated from a web request. You might use links when a trace passes through an asynchronous queue or when correlating traces from different services. ### Span filters From 08335a0068516015bc7a680cd54684f377cb6f18 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 02:32:21 +0200 Subject: [PATCH 27/79] I18n: Download translations from Crowdin (#102576) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/de-DE/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/es-ES/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/fr-FR/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/hu-HU/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/id-ID/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/it-IT/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/ja-JP/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/ko-KR/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/nl-NL/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/pl-PL/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/pt-BR/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/pt-PT/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/ru-RU/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/sv-SE/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/tr-TR/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/zh-Hans/grafana.json | 89 ++++++++++++++++++++++++++--- public/locales/zh-Hant/grafana.json | 89 ++++++++++++++++++++++++++--- 18 files changed, 1458 insertions(+), 144 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 01f18df8845..823eb06d55d 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -1457,6 +1457,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1471,12 +1480,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1500,6 +1503,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1540,7 +1555,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1551,6 +1565,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1574,7 +1648,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 1a568cdeb88..c627144ef51 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "Als Favorit markieren", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "Original-Dashboard öffnen", "playlist-next": "Zum nächsten Dashboard", "playlist-previous": "Zum vorherigen Dashboard", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "Teilen", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "Markierung als Favorit entfernen" diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index b3a38cb6018..13902cc2b51 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "Marcar como favorito", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "Abrir el panel de control original", "playlist-next": "Ir al siguiente panel de control", "playlist-previous": "Ir al panel de control anterior", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "Compartir", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "Deshacer marca como favorito" diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 4ea1a67cf89..d00c65e191b 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "Marquer comme favori", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "Ouvrir le tableau de bord d'origine", "playlist-next": "Accéder au tableau de bord suivant", "playlist-previous": "Accéder au tableau de bord précédent", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "Partager", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "Supprimer des favoris" diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index f7dea7cfce7..750d7a13e93 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 6afeeafec1f..e4f5871bcb3 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -1430,6 +1430,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1444,12 +1453,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1473,6 +1476,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1513,7 +1528,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1524,6 +1538,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1547,7 +1621,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index f7dea7cfce7..750d7a13e93 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 6afeeafec1f..e4f5871bcb3 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -1430,6 +1430,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1444,12 +1453,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1473,6 +1476,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1513,7 +1528,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1524,6 +1538,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1547,7 +1621,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 6afeeafec1f..e4f5871bcb3 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -1430,6 +1430,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1444,12 +1453,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1473,6 +1476,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1513,7 +1528,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1524,6 +1538,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1547,7 +1621,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index f7dea7cfce7..750d7a13e93 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 01f18df8845..823eb06d55d 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -1457,6 +1457,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1471,12 +1480,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1500,6 +1503,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1540,7 +1555,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1551,6 +1565,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1574,7 +1648,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index da9afdb99be..b89eab92112 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "Marcar como favorito", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "Abrir painel de controle original", "playlist-next": "Ir para o próximo painel de controle", "playlist-previous": "Ir para o painel de controle anterior", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "Compartilhar", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "Desmarcar como favorito" diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index f7dea7cfce7..750d7a13e93 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 01f18df8845..823eb06d55d 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -1457,6 +1457,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1471,12 +1480,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1500,6 +1503,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1540,7 +1555,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1551,6 +1565,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1574,7 +1648,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index f7dea7cfce7..750d7a13e93 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index f7dea7cfce7..750d7a13e93 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -1439,6 +1439,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1453,12 +1462,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1482,6 +1485,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1522,7 +1537,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1533,6 +1547,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1556,7 +1630,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 1137c90e5ff..c78366e48f8 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1430,6 +1430,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1444,12 +1453,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1473,6 +1476,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1513,7 +1528,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1524,6 +1538,66 @@ }, "mark-favorite": "标记为收藏", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "打开原始仪表板", "playlist-next": "前往下一个仪表板", "playlist-previous": "前往上一个仪表板", @@ -1547,7 +1621,6 @@ "tooltip": "" }, "share-button": "分享", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "取消标记为收藏" diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 6afeeafec1f..e4f5871bcb3 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -1430,6 +1430,15 @@ }, "responsive-layout": { "description": "", + "item-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, "name": "", "options": { "columns": "", @@ -1444,12 +1453,6 @@ "rows-layout": { "description": "", "name": "", - "option": { - "height": "", - "hide-header": "", - "repeat": "", - "title": "" - }, "options": { "height-expand": "", "height-min": "" @@ -1473,6 +1476,18 @@ } }, "row-options": { + "repeat": { + "title": "", + "variable": { + "description": "", + "title": "" + } + }, + "row": { + "height": "", + "hide-header": "", + "title": "" + }, "title-option": "" } }, @@ -1513,7 +1528,6 @@ "label": "", "tooltip": "" }, - "edit-dashboard-v2-schema": "", "enter-edit-mode": { "label": "", "tooltip": "" @@ -1524,6 +1538,66 @@ }, "mark-favorite": "", "more-save-options": "", + "new": { + "back-to-dashboard": "", + "dashboard-settings": { + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit-dashboard-v2-schema": { + "tooltip": "" + }, + "edit-toggle": { + "enter": { + "label": "" + }, + "exit": { + "label": "" + } + }, + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "export": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "mark-favorite": "", + "more-save-options": "", + "playlist-next": "", + "playlist-previous": "", + "playlist-stop": "", + "public-dashboard": "", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", + "share": { + "arrow": "", + "title": "", + "tooltip": "" + }, + "share-export": { + "modal": { + "noText": "", + "text": "", + "title": "", + "yesText": "" + } + }, + "unlink-library-panel": "", + "unmark-favorite": "" + }, "open-original": "", "playlist-next": "", "playlist-previous": "", @@ -1547,7 +1621,6 @@ "tooltip": "" }, "share-button": "", - "show-hidden-elements": "", "switch-old-dashboard": "", "unlink-library-panel": "", "unmark-favorite": "" From da95ee22ccd649d5daa134995e1747763b2cfc84 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Thu, 20 Mar 2025 23:47:09 -0600 Subject: [PATCH 28/79] K8s: Fix dashboard creation timestamp (#102578) --- pkg/registry/apis/dashboard/legacy/sql_dashboards.go | 1 + pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 8f07f37658b..bc9b5a2c4e0 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -404,6 +404,7 @@ func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgI }) if old != nil { dash.Spec.Set("id", old.ID) + dash.Spec.Set("version", float64(old.Version)) } else { dash.Spec.Remove("id") // existing of "id" makes it an update created = true diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go index 2e8d5485e9e..efa4ffde96f 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go @@ -169,6 +169,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) { mockStore.On("GetDashboard", mock.Anything, mock.Anything).Return( &dashboards.Dashboard{ ID: 1234, + Version: 2, APIVersion: "dashboard.grafana.app/v0alpha1", }, nil).Once() cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash) @@ -176,8 +177,9 @@ func TestBuildSaveDashboardCommand(t *testing.T) { require.Equal(t, false, created) require.NotNil(t, cmd) require.Equal(t, "test-dash", cmd.Dashboard.Get("uid").MustString()) - require.Equal(t, cmd.Dashboard.Get("id").MustInt64(), int64(1234)) // should set to existing ID - require.Equal(t, cmd.APIVersion, "v0alpha1") // should trim prefix + require.Equal(t, cmd.Dashboard.Get("id").MustInt64(), int64(1234)) // should set to existing ID + require.Equal(t, cmd.Dashboard.Get("version").MustFloat64(), float64(2)) // version must be set - otherwise seen as a new dashboard in NewDashboardFromJson + require.Equal(t, cmd.APIVersion, "v0alpha1") // should trim prefix require.Equal(t, cmd.OrgID, int64(1)) require.True(t, cmd.Overwrite) } From 996ff7d65e0a808a933250e9166997f4bdbd67c2 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Fri, 21 Mar 2025 01:19:33 -0600 Subject: [PATCH 29/79] K8s: Fix dashboard history list timestamps (#102580) --- .../dashboardversion/dashverimpl/dashver.go | 6 +++++- .../dashboardversion/dashverimpl/dashver_test.go | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go index 31103e1821a..03b9b9ee2d9 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver.go @@ -308,6 +308,10 @@ func (s *Service) UnstructuredToLegacyDashboardVersion(ctx context.Context, item createdBy = updatedBy } } + created := obj.GetCreationTimestamp().Time + if updated, err := obj.GetUpdatedTimestamp(); err == nil && updated != nil { + created = *updated + } id, err := obj.GetResourceVersionInt64() if err != nil { @@ -323,7 +327,7 @@ func (s *Service) UnstructuredToLegacyDashboardVersion(ctx context.Context, item ID: id, DashboardID: obj.GetDeprecatedInternalID(), // nolint:staticcheck DashboardUID: uid, - Created: obj.GetCreationTimestamp().Time, + Created: created, CreatedBy: createdBy.ID, Message: obj.GetMessage(), RestoredFrom: restoreVer, diff --git a/pkg/services/dashboardversion/dashverimpl/dashver_test.go b/pkg/services/dashboardversion/dashverimpl/dashver_test.go index bd022a00640..1e34b144d30 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver_test.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -49,8 +50,9 @@ func TestDashboardVersionService(t *testing.T) { dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders) dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil) - mockCli.On("GetUserFromMeta", mock.Anything, "user:1").Return(&user.User{ID: 1}, nil) - mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "10"}, mock.Anything).Return(&unstructured.Unstructured{ + creationTimestamp := time.Now().Add(time.Hour * -24).UTC() + updatedTimestamp := time.Now().UTC().Truncate(time.Second) + dash := &unstructured.Unstructured{ Object: map[string]any{ "metadata": map[string]any{ "name": "uid", @@ -66,7 +68,13 @@ func TestDashboardVersionService(t *testing.T) { "spec": map[string]any{ "hello": "world", }, - }}, nil).Once() + }} + dash.SetCreationTimestamp(v1.NewTime(creationTimestamp)) + obj, err := utils.MetaAccessor(dash) + require.NoError(t, err) + obj.SetUpdatedTimestamp(&updatedTimestamp) + mockCli.On("GetUserFromMeta", mock.Anything, "user:1").Return(&user.User{ID: 1}, nil) + mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "10"}, mock.Anything).Return(dash, nil).Once() res, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{ DashboardID: 42, OrgID: 1, @@ -80,6 +88,7 @@ func TestDashboardVersionService(t *testing.T) { DashboardID: 42, DashboardUID: "uid", CreatedBy: 1, + Created: updatedTimestamp, Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(10), "hello": "world"}), }) From 2e2b5942c81758e42e41bfbbc4607ece56cde658 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 21 Mar 2025 11:45:25 +0300 Subject: [PATCH 30/79] K8s/Unified: Consolidate generation logic in apistore client (#102260) --- pkg/apimachinery/utils/meta.go | 9 ++ pkg/apiserver/registry/generic/strategy.go | 18 +-- .../registry/generic/strategy_test.go | 34 +--- pkg/registry/apis/dashboard/register.go | 1 + pkg/registry/apis/folders/register.go | 3 +- pkg/storage/unified/apistore/prepare.go | 38 ++++- pkg/storage/unified/apistore/prepare_test.go | 153 +++++++++++++++++- pkg/storage/unified/apistore/store.go | 6 + pkg/tests/apis/playlist/playlist_test.go | 31 +++- 9 files changed, 237 insertions(+), 56 deletions(-) diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index 5ae3014c8c3..67076f2e96a 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -86,6 +86,7 @@ type GrafanaMetaAccessor interface { GetMessage() string SetMessage(msg string) SetAnnotation(key string, val string) + GetAnnotation(key string) string SetBlob(v *BlobInfo) GetBlob() *BlobInfo @@ -192,6 +193,14 @@ func (m *grafanaMetaAccessor) SetAnnotation(key string, val string) { m.obj.SetAnnotations(anno) } +func (m *grafanaMetaAccessor) GetAnnotation(key string) string { + anno := m.obj.GetAnnotations() + if anno != nil { + return anno[key] + } + return "" +} + func (m *grafanaMetaAccessor) get(key string) string { return m.obj.GetAnnotations()[key] } diff --git a/pkg/apiserver/registry/generic/strategy.go b/pkg/apiserver/registry/generic/strategy.go index 6cb5dd918fd..5293335f1f3 100644 --- a/pkg/apiserver/registry/generic/strategy.go +++ b/pkg/apiserver/registry/generic/strategy.go @@ -3,8 +3,6 @@ package generic import ( "context" - "github.com/grafana/grafana/pkg/apimachinery/utils" - apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" @@ -14,6 +12,8 @@ import ( "k8s.io/apiserver/pkg/storage" "k8s.io/apiserver/pkg/storage/names" "sigs.k8s.io/structured-merge-diff/v4/fieldpath" + + "github.com/grafana/grafana/pkg/apimachinery/utils" ) type genericStrategy struct { @@ -81,20 +81,6 @@ func (g *genericStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime } else { _ = newMeta.SetStatus(status) } - - spec, err := newMeta.GetSpec() - if err != nil { - return - } - - oldSpec, err := oldMeta.GetSpec() - if err != nil { - return - } - - if !apiequality.Semantic.DeepEqual(spec, oldSpec) { - newMeta.SetGeneration(oldMeta.GetGeneration() + 1) - } } func (g *genericStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { diff --git a/pkg/apiserver/registry/generic/strategy_test.go b/pkg/apiserver/registry/generic/strategy_test.go index 969332305a2..906713d18fe 100644 --- a/pkg/apiserver/registry/generic/strategy_test.go +++ b/pkg/apiserver/registry/generic/strategy_test.go @@ -4,12 +4,13 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/apis/example" + + "github.com/grafana/grafana/pkg/apiserver/registry/generic" ) func TestPrepareForUpdate(t *testing.T) { @@ -69,37 +70,6 @@ func TestPrepareForUpdate(t *testing.T) { }, }, }, - { - name: "increment generation if spec changes", - newObj: &example.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Generation: 1, - }, - Spec: example.PodSpec{ - NodeSelector: map[string]string{"foo": "baz"}, - }, - Status: example.PodStatus{ - Phase: example.PodPhase("Running"), - }, - }, - oldObj: oldObj.DeepCopy(), - expectedGen: 2, - expectedObj: &example.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Generation: 2, - }, - Spec: example.PodSpec{ - NodeSelector: map[string]string{"foo": "baz"}, - }, - Status: example.PodStatus{ - Phase: example.PodPhase("Running"), - }, - }, - }, } for _, tc := range testCases { diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 101d36cd2da..c8a7b6f9f55 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -184,6 +184,7 @@ func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attribu func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storageOpts := apistore.StorageOptions{ + EnableFolderSupport: true, RequireDeprecatedInternalID: true, } diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index ca3d492815d..df8b2431f2f 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - authtypes "github.com/grafana/authlib/types" "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -18,6 +17,7 @@ import ( common "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + authtypes "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" @@ -156,6 +156,7 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API } opts.StorageOptions(resourceInfo.GroupResource(), apistore.StorageOptions{ + EnableFolderSupport: true, RequireDeprecatedInternalID: true}) folderStore := &folderStorage{ diff --git a/pkg/storage/unified/apistore/prepare.go b/pkg/storage/unified/apistore/prepare.go index c9677fd0763..e6d9458ebfb 100644 --- a/pkg/storage/unified/apistore/prepare.go +++ b/pkg/storage/unified/apistore/prepare.go @@ -9,13 +9,14 @@ import ( "time" "github.com/google/uuid" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/storage" "k8s.io/klog/v2" authtypes "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -57,6 +58,9 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime if obj.GetUID() == "" { obj.SetUID(types.UID(uuid.NewString())) } + if obj.GetFolder() != "" && !s.opts.EnableFolderSupport { + return nil, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String())) + } if s.opts.RequireDeprecatedInternalID { // nolint:staticcheck @@ -77,6 +81,7 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime obj.SetUpdatedBy("") obj.SetUpdatedTimestamp(nil) obj.SetCreatedBy(info.GetUID()) + obj.SetGeneration(1) // the first time we write var buf bytes.Buffer if err = s.codec.Encode(newObject, &buf); err != nil { @@ -131,8 +136,35 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti obj.SetDeprecatedInternalID(previousInternalID) // nolint:staticcheck } - obj.SetUpdatedBy(info.GetUID()) - obj.SetUpdatedTimestampMillis(time.Now().UnixMilli()) + // Check if we should bump the generation + changed := obj.GetFolder() != previous.GetFolder() + if changed { + if !s.opts.EnableFolderSupport { + return nil, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String())) + } + // TODO: check that we can move the folder? + } else if obj.GetDeletionTimestamp() != nil && previous.GetDeletionTimestamp() == nil { + changed = true // bump generation when deleted + } else { + spec, e1 := obj.GetSpec() + oldSpec, e2 := previous.GetSpec() + if e1 == nil && e2 == nil { + if !apiequality.Semantic.DeepEqual(spec, oldSpec) { + changed = true + } + } + } + + // Mark the resource as changed + if changed { + obj.SetGeneration(previous.GetGeneration() + 1) + obj.SetUpdatedBy(info.GetUID()) + obj.SetUpdatedTimestampMillis(time.Now().UnixMilli()) + } else { + obj.SetGeneration(previous.GetGeneration()) + obj.SetAnnotation(utils.AnnoKeyUpdatedBy, previous.GetAnnotation(utils.AnnoKeyUpdatedBy)) + obj.SetAnnotation(utils.AnnoKeyUpdatedTimestamp, previous.GetAnnotation(utils.AnnoKeyUpdatedTimestamp)) + } var buf bytes.Buffer if err = s.codec.Encode(updateObject, &buf); err != nil { diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go index 1bb9c06cd04..1c17a9806c2 100644 --- a/pkg/storage/unified/apistore/prepare_test.go +++ b/pkg/storage/unified/apistore/prepare_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/exp/rand" "k8s.io/apimachinery/pkg/api/apitesting" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/storage" @@ -30,11 +32,14 @@ func TestPrepareObjectForStorage(t *testing.T) { codec: apitesting.TestCodec(codecs, v0alpha1.DashboardResourceInfo.GroupVersion()), snowflake: node, opts: StorageOptions{ - LargeObjectSupport: nil, + EnableFolderSupport: true, + LargeObjectSupport: nil, }, } - ctx := authtypes.WithAuthInfo(context.Background(), &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authtypes.TypeUser}) + ctx := authtypes.WithAuthInfo(context.Background(), + &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authtypes.TypeUser}, + ) t.Run("Error getting auth info from context", func(t *testing.T) { _, err := s.prepareObjectForStorage(context.Background(), nil) @@ -81,7 +86,7 @@ func TestPrepareObjectForStorage(t *testing.T) { require.Empty(t, updatedTS) }) - t.Run("Should keep repo info", func(t *testing.T) { + t.Run("Should keep manager info", func(t *testing.T) { dashboard := v0alpha1.Dashboard{} dashboard.Name = "test-name" obj := dashboard.DeepCopyObject() @@ -117,6 +122,65 @@ func TestPrepareObjectForStorage(t *testing.T) { require.Equal(t, s.TimestampMillis, now.UnixMilli()) }) + t.Run("Update should manage incrementing generation and metadata", func(t *testing.T) { + dashboard := v0alpha1.Dashboard{} + dashboard.Name = "test-name" + obj := dashboard.DeepCopyObject() + meta, err := utils.MetaAccessor(obj) + meta.SetFolder("aaa") + require.NoError(t, err) + + encodedData, err := s.prepareObjectForStorage(ctx, obj) + require.NoError(t, err) + + insertedObject, _, err := s.codec.Decode(encodedData, nil, &v0alpha1.Dashboard{}) + require.NoError(t, err) + meta, err = utils.MetaAccessor(insertedObject) + require.NoError(t, err) + require.Equal(t, int64(1), meta.GetGeneration()) + require.Equal(t, "user:user-uid", meta.GetCreatedBy()) + require.Equal(t, "", meta.GetUpdatedBy()) // empty + ts, err := meta.GetUpdatedTimestamp() + require.NoError(t, err) + require.Nil(t, ts) + + // Change the user... and only update metadata + ctx = authtypes.WithAuthInfo(context.Background(), + &identity.StaticRequester{UserID: 1, UserUID: "user2", Type: authtypes.TypeUser}, + ) + + // Change the status... but generation is the same + updatedObject := insertedObject.DeepCopyObject() + meta, err = utils.MetaAccessor(updatedObject) + require.NoError(t, err) + err = meta.SetStatus(v0alpha1.DashboardStatus{ + Conversion: &v0alpha1.DashboardConversionStatus{ + Failed: true, + Error: "test", + }, + }) + require.NoError(t, err) + meta.SetGeneration(123) // will be removed + + // Update status without changing generation or update metadata + _, err = s.prepareObjectForUpdate(ctx, updatedObject, insertedObject) + require.NoError(t, err) + require.Equal(t, "", meta.GetUpdatedBy()) + require.Equal(t, int64(1), meta.GetGeneration()) + + // Change the folder -- the generation should increase and the updatedBy metadata + dashboard2 := &v0alpha1.Dashboard{ObjectMeta: v1.ObjectMeta{ + Name: dashboard.Name, + }} // TODO... deep copy, See: https://github.com/grafana/grafana/pull/102258 + meta2, err := utils.MetaAccessor(dashboard2) + require.NoError(t, err) + meta2.SetFolder("xyz") // will bump generation + _, err = s.prepareObjectForUpdate(ctx, dashboard2, updatedObject) + require.NoError(t, err) + require.Equal(t, "user:user2", meta2.GetUpdatedBy()) + require.Equal(t, int64(2), meta2.GetGeneration()) + }) + s.opts.RequireDeprecatedInternalID = true t.Run("Should generate internal id", func(t *testing.T) { dashboard := v0alpha1.Dashboard{} @@ -149,4 +213,87 @@ func TestPrepareObjectForStorage(t *testing.T) { require.NoError(t, err) require.Equal(t, meta.GetDeprecatedInternalID(), int64(1)) // nolint:staticcheck }) + + t.Run("calculate generation", func(t *testing.T) { + dash := &v0alpha1.Dashboard{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + }, + Spec: v0alpha1.DashboardSpec{ + Object: map[string]interface{}{ + "hello": "world", + }, + }, + } + out := getPreparedObject(t, ctx, s, dash, nil) + require.Equal(t, int64(1), out.GetGeneration()) + require.NotEmpty(t, out.GetAnnotation(utils.AnnoKeyCreatedBy)) + require.Equal(t, "", out.GetAnnotation(utils.AnnoKeyUpdatedBy)) + require.Equal(t, "", out.GetAnnotation(utils.AnnoKeyUpdatedTimestamp)) + + t.Run("increment when the spec changes", func(t *testing.T) { + b := dash.DeepCopy() + b.Spec.Object["x"] = "y" + out = getPreparedObject(t, ctx, s, b, dash) + require.Equal(t, int64(2), out.GetGeneration()) + require.NotEmpty(t, out.GetAnnotation(utils.AnnoKeyUpdatedBy)) + require.NotEmpty(t, out.GetAnnotation(utils.AnnoKeyUpdatedTimestamp)) + }) + + t.Run("increment when the folder changes", func(t *testing.T) { + b := dash.DeepCopy() + b.Annotations = map[string]string{ + utils.AnnoKeyFolder: "abc", + } + out = getPreparedObject(t, ctx, s, b, dash) + require.Equal(t, int64(2), out.GetGeneration()) + }) + + t.Run("increment when deleted", func(t *testing.T) { + now := v1.Now() + b := dash.DeepCopy() + b.DeletionTimestamp = &now + out = getPreparedObject(t, ctx, s, b, dash) + require.Equal(t, int64(2), out.GetGeneration()) + }) + + t.Run("keep when status, labels, or annotations change", func(t *testing.T) { + b := dash.DeepCopy() + b.Annotations = map[string]string{ + "x": "hello", + } + b.Labels = map[string]string{ + "a": "b", + } + b.Status = v0alpha1.DashboardStatus{ + Conversion: &v0alpha1.DashboardConversionStatus{ + Failed: true, + }, + } + out = getPreparedObject(t, ctx, s, b, dash) + require.Equal(t, int64(1), out.GetGeneration()) // still 1 + }) + }) +} + +func getPreparedObject(t *testing.T, ctx context.Context, s *Storage, obj runtime.Object, old runtime.Object) utils.GrafanaMetaAccessor { + t.Helper() + + var raw []byte + var err error + + if old == nil { + raw, err = s.prepareObjectForStorage(ctx, obj) + } else { + raw, err = s.prepareObjectForUpdate(ctx, obj, old) + } + require.NoError(t, err) + + out := &unstructured.Unstructured{} + err = out.UnmarshalJSON(raw) + require.NoError(t, err) + + meta, err := utils.MetaAccessor(out) + require.NoError(t, err) + return meta } diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index 966ba119c0a..4557c0a5d39 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -47,8 +47,14 @@ var _ storage.Interface = (*Storage)(nil) // Optional settings that apply to a single resource type StorageOptions struct { + // ????: should we constrain this to only dashboards for now? + // Not yet clear if this is a good general solution, or just a stop-gap LargeObjectSupport LargeObjectSupport + // Allow writing objects with metadata.annotations[grafana.app/folder] + EnableFolderSupport bool + + // Add internalID label when missing RequireDeprecatedInternalID bool } diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index a7b61abec50..a30aeba2fc2 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -10,11 +10,13 @@ import ( "testing" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana/pkg/apimachinery/utils" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -156,7 +158,7 @@ func TestIntegrationPlaylist(t *testing.T) { }) t.Run("with dual write (file, mode 5)", func(t *testing.T) { - doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + helper := doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "file", // write the files to disk @@ -169,6 +171,33 @@ func TestIntegrationPlaylist(t *testing.T) { featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written }, })) + + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + // Folder support needs to be enabled explicitly for this resource + t.Run("ensure writing folders is an error", func(t *testing.T) { + // Create works without folder + obj := helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml") + out, err := client.Resource.Create(context.Background(), obj, metav1.CreateOptions{}) + require.NoError(t, err) + + meta, err := utils.MetaAccessor(out) + require.NoError(t, err) + require.Equal(t, int64(1), meta.GetGeneration()) + require.Equal(t, helper.Org1.Editor.Identity.GetUID(), meta.GetCreatedBy()) + require.Equal(t, "", meta.GetUpdatedBy()) + + meta, err = utils.MetaAccessor(obj) + require.NoError(t, err) + meta.SetFolder("FolderUID") + + _, err = client.Resource.Create(context.Background(), obj, metav1.CreateOptions{}) + require.Error(t, err) + require.True(t, apierrors.IsBadRequest(err)) + }) }) t.Run("with dual write (unified storage, mode 0)", func(t *testing.T) { From d7fe097630950ab274348907c95f49c6c1593d15 Mon Sep 17 00:00:00 2001 From: Jo Date: Fri, 21 Mar 2025 10:07:52 +0100 Subject: [PATCH 31/79] Docs: Add documentation on the cloud access policy permissions (#102550) * add admonition * add to menu * add missing entry * make it prettier * fix ref --- .../custom-role-actions-scopes/index.md | 14 ++++++++++++++ .../access-control/rbac-for-app-plugins/index.md | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index baab890cbef..8859c8964b4 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -205,6 +205,20 @@ The following list contains role-based access control actions used by Grafana Ad | `grafana-adaptive-metrics-app.exemptions:read` | None | Read recommendation exemptions. | | `grafana-adaptive-metrics-app.exemptions:write` | None | Create, update, and delete recommendation exemptions. | +### Cloud Access Policies action definitions + +The following list contains role-based access control actions used by Cloud Access Policies. + +| Action | Applicable scopes | Description | +| ------------------------ | ----------------- | ------------------------------------------------------------------- | +| `grafana-auth-app:write` | None | Create, read, update, and delete access policies for Grafana Cloud. | + +{{< admonition type="warning" >}} +Granting the `grafana-auth-app:write` permission is equivalent to assigning the Admin role to a user in Grafana, as it allows them to manage all stack service accounts. This provides significant privileges and should be assigned with caution. +{{< /admonition >}} + +For more information on Cloud Access Policies and how to use them, see [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/). + ### Grafana Alerting Notification action definitions To use these permissions, enable the `alertingApiServer` feature toggle. diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md index 020bf9c982c..46304ed2000 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md @@ -24,6 +24,9 @@ refs: destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/#grafana-adaptive-metrics-action-definitions - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/#grafana-adaptive-metrics-action-definitions + cloud-access-policies-action-definitions: + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions rbac-role-definitions: - pattern: /docs/grafana/ destination: /docs/grafana//administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/ @@ -62,7 +65,7 @@ The following list contains app plugins that have fine-grained RBAC support. | App plugin | App plugin ID | App plugin permission documentation | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | n/a | +| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) | | [Adaptive metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) | | [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a | | [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) | From bf456179e7d1fcc1d9f02c910b929a3d713efffb Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Fri, 21 Mar 2025 10:08:23 +0100 Subject: [PATCH 32/79] Prometheus: Use timeRange parameter in each language provider method (#101889) * remove range from class * remove range from language provider * use range parameters in MetricsLabelsSection * use range parameters in metric_find_query * pass range parameter in monaco-query-field * typo * use range in prometheus metrics browser languageProvider calls * fix unit tests * fix unit tests * update unit tests * lint --- .../src/components/PromQueryField.tsx | 2 + .../PrometheusMetricsBrowser.test.tsx | 7 +- .../components/PrometheusMetricsBrowser.tsx | 17 ++- .../src/components/VariableQueryEditor.tsx | 24 ++-- .../monaco-query-field/MonacoQueryField.tsx | 4 +- .../MonacoQueryFieldProps.ts | 3 +- .../completions.test.ts | 41 ++++--- .../monaco-completion-provider/completions.ts | 46 +++++--- .../monaco-completion-provider/index.ts | 7 +- packages/grafana-prometheus/src/datasource.ts | 19 +++- .../src/language_provider.test.ts | 32 +++--- .../src/language_provider.ts | 103 ++++++++---------- .../src/metric_find_query.ts | 40 +++---- .../components/MetricsLabelsSection.tsx | 25 +++-- .../components/PromQueryBuilder.test.tsx | 22 +++- .../components/PromQueryBuilder.tsx | 9 +- 16 files changed, 234 insertions(+), 167 deletions(-) diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index 4c619f88b7a..05cdda9864c 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -3,6 +3,7 @@ import { css, cx } from '@emotion/css'; import { PureComponent, ReactNode } from 'react'; import { + getDefaultTimeRange, isDataFrame, LocalStorageValueProvider, QueryEditorProps, @@ -249,6 +250,7 @@ class PromQueryFieldClass extends PureComponent diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx index df14c9e02c8..602aeca8f6a 100644 --- a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createTheme } from '@grafana/data'; +import { createTheme, getDefaultTimeRange, TimeRange } from '@grafana/data'; import PromQlLanguageProvider from '../language_provider'; @@ -146,7 +146,7 @@ describe('PrometheusMetricsBrowser', () => { const setupProps = (): BrowserProps => { const mockLanguageProvider = { start: () => Promise.resolve(), - getLabelValues: (name: string) => { + getLabelValues: (timeRange: TimeRange, name: string) => { switch (name) { case 'label1': return ['value1-1', 'value1-2']; @@ -163,7 +163,7 @@ describe('PrometheusMetricsBrowser', () => { // The metrics browser expects both label names and label values. // The labels endpoint with match does not supply label values // and so using it breaks the metrics browser. - fetchSeriesLabels: (selector: string) => { + fetchSeriesLabels: (timeRange: TimeRange, selector: string) => { switch (selector) { case '{label1="value1-1"}': return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] }; @@ -187,6 +187,7 @@ describe('PrometheusMetricsBrowser', () => { lastUsedLabels: [], storeLastUsedLabels: () => {}, deleteLastUsedLabels: () => {}, + timeRange: getDefaultTimeRange(), }; return defaults; diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx index 1eeb59d35ed..db3ec6c60b4 100644 --- a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx @@ -4,7 +4,7 @@ import { ChangeEvent } from 'react'; import * as React from 'react'; import { FixedSizeList } from 'react-window'; -import { GrafanaTheme2, TimeRange } from '@grafana/data'; +import { getDefaultTimeRange, GrafanaTheme2, TimeRange } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { BrowserLabel as PromLabel, @@ -409,11 +409,15 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component { + return this.props.timeRange ?? getDefaultTimeRange(); + }; + async fetchValues(name: string, selector: string) { const { languageProvider } = this.props; this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); try { - let rawValues = await languageProvider.getLabelValues(name); + let rawValues = await languageProvider.getLabelValues(this.getTimeRange(), name); // If selector changed, clear loading state and discard result by returning early if (selector !== buildSelector(this.state.labels)) { this.updateLabelState(name, { loading: false }); @@ -444,7 +448,12 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component ({ label: variable, value: variable })); + let timeRange = range; + if (!timeRange) { + timeRange = getDefaultTimeRange(); + } + if (!metric) { // get all the labels - datasource.getTagKeys({ filters: [] }).then((labelNames: Array<{ text: string }>) => { + datasource.getTagKeys({ timeRange, filters: [] }).then((labelNames: Array<{ text: string }>) => { const names = labelNames.map(({ text }) => ({ label: text, value: text })); setLabels(names, variables); }); @@ -122,13 +127,15 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: const labelToConsider = [{ label: '__name__', op: '=', value: metric }]; const expr = promQueryModeller.renderLabels(labelToConsider); - datasource.languageProvider.fetchLabelsWithMatch(expr).then((labelsIndex: Record) => { - const labelNames = Object.keys(labelsIndex); - const names = labelNames.map((value) => ({ label: value, value: value })); - setLabels(names, variables); - }); + datasource.languageProvider + .fetchLabelsWithMatch(timeRange, expr) + .then((labelsIndex: Record) => { + const labelNames = Object.keys(labelsIndex); + const names = labelNames.map((value) => ({ label: value, value: value })); + setLabels(names, variables); + }); } - }, [datasource, qryType, metric]); + }, [datasource, qryType, metric, range]); const onChangeWithVariableString = ( updateVar: { [key: string]: QueryType | string }, @@ -302,6 +309,7 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: datasource={datasource} onChange={metricsLabelsChange} variableEditor={true} + timeRange={range ?? getDefaultTimeRange()} /> )} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx index 97e49df416b..d6c86499c25 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx @@ -105,7 +105,7 @@ const MonacoQueryField = (props: Props) => { // we need only one instance of `overrideServices` during the lifetime of the react component const overrideServicesRef = useRef(getOverrideServices()); const containerRef = useRef(null); - const { languageProvider, history, onBlur, onRunQuery, initialValue, placeholder, datasource } = props; + const { languageProvider, history, onBlur, onRunQuery, initialValue, placeholder, datasource, timeRange } = props; const lpRef = useLatest(languageProvider); const historyRef = useLatest(history); @@ -155,7 +155,7 @@ const MonacoQueryField = (props: Props) => { historyProvider: historyRef.current, languageProvider: lpRef.current, }); - const completionProvider = getCompletionProvider(monaco, dataProvider); + const completionProvider = getCompletionProvider(monaco, dataProvider, timeRange); // completion-providers in monaco are not registered directly to editor-instances, // they are registered to languages. this makes it hard for us to have diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts index 4ca3fd894ed..ac2995273e5 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryFieldProps.ts @@ -1,5 +1,5 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryFieldProps.ts -import { HistoryItem } from '@grafana/data'; +import { HistoryItem, TimeRange } from '@grafana/data'; import { PrometheusDatasource } from '../../datasource'; import type PromQlLanguageProvider from '../../language_provider'; @@ -17,4 +17,5 @@ export type Props = { onRunQuery: (value: string) => void; onBlur: (value: string) => void; datasource: PrometheusDatasource; + timeRange: TimeRange; }; diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts index 7a97ba98740..a778cf74300 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -2,6 +2,7 @@ import { config } from '@grafana/runtime'; import { SUGGESTIONS_LIMIT } from '../../../language_provider'; import { FUNCTIONS } from '../../../promql'; +import { getMockTimeRange } from '../../../test/__mocks__/datasource'; import { filterMetricNames, getCompletions } from './completions'; import { DataProvider, type DataProviderParams } from './data_provider'; @@ -183,6 +184,8 @@ function getSuggestionCountForSituation(situationType: MetricNameSituation, metr } describe.each(metricNameCompletionSituations)('metric name completions in situation %s', (situationType) => { + const timeRange = getMockTimeRange(); + it('should return completions for all metric names when the number of metric names is at or below the limit', async () => { jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.atLimit); const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length); @@ -192,12 +195,12 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat // No text input dataProvider.monacoSettings.setInputInRange(''); - let completions = await getCompletions(situation, dataProvider); + let completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(expectedCompletionsCount); // With text input (use fuzzy search) dataProvider.monacoSettings.setInputInRange('name_1'); - completions = await getCompletions(situation, dataProvider); + completions = await getCompletions(situation, dataProvider, timeRange); expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); }); @@ -210,12 +213,12 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat // Complex query dataProvider.monacoSettings.setInputInRange('metric name one two three four five'); - let completions = await getCompletions(situation, dataProvider); + let completions = await getCompletions(situation, dataProvider, timeRange); expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); // Simple query with fuzzy match dataProvider.monacoSettings.setInputInRange('metric_name_'); - completions = await getCompletions(situation, dataProvider); + completions = await getCompletions(situation, dataProvider, timeRange); expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); }); @@ -227,19 +230,19 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat // Do not cross the metrics names threshold jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.atLimit); dataProvider.monacoSettings.setInputInRange('name_1'); - await getCompletions(situation, dataProvider); + await getCompletions(situation, dataProvider, timeRange); expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false); // Cross the metric names threshold, without text input jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit); dataProvider.monacoSettings.setInputInRange(''); - await getCompletions(situation, dataProvider); + await getCompletions(situation, dataProvider, timeRange); expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); // Cross the metric names threshold, with text input jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit); dataProvider.monacoSettings.setInputInRange('name_1'); - await getCompletions(situation, dataProvider); + await getCompletions(situation, dataProvider, timeRange); expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); }); @@ -253,7 +256,7 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat // Test with a complex query (> 4 terms) dataProvider.monacoSettings.setInputInRange('metric name 1 with extra terms more'); - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); const metricCompletions = completions.filter((c) => c.type === 'METRIC_NAME'); expect(metricCompletions.some((c) => c.label === 'metric_name_1_with_extra_terms')).toBe(true); @@ -268,7 +271,7 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat // Test with multiple terms dataProvider.monacoSettings.setInputInRange('metric name 1 2 3 4 5'); - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length); expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); @@ -308,6 +311,8 @@ describe('Label value completions', () => { }); }); + const timeRange = getMockTimeRange(); + it('should not escape special characters when between quotes', async () => { const situation: Situation = { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', @@ -316,7 +321,7 @@ describe('Label value completions', () => { otherLabels: [], }; - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(4); expect(completions[0].insertText).toBe('value1'); @@ -333,7 +338,7 @@ describe('Label value completions', () => { otherLabels: [], }; - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(4); expect(completions[0].insertText).toBe('"value1"'); @@ -350,6 +355,8 @@ describe('Label value completions', () => { }); }); + const timeRange = getMockTimeRange(); + it('should escape special characters when between quotes', async () => { const situation: Situation = { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', @@ -358,7 +365,7 @@ describe('Label value completions', () => { otherLabels: [], }; - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(4); expect(completions[0].insertText).toBe('value1'); @@ -375,7 +382,7 @@ describe('Label value completions', () => { otherLabels: [], }; - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(4); expect(completions[0].insertText).toBe('"value1"'); @@ -392,6 +399,8 @@ describe('Label value completions', () => { }); }); + const timeRange = getMockTimeRange(); + it('should handle empty values', async () => { jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['']); @@ -402,7 +411,7 @@ describe('Label value completions', () => { otherLabels: [], }; - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(1); expect(completions[0].insertText).toBe('""'); }); @@ -417,7 +426,7 @@ describe('Label value completions', () => { otherLabels: [], }; - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(1); expect(completions[0].insertText).toBe('test\\"\\\\value'); }); @@ -432,7 +441,7 @@ describe('Label value completions', () => { otherLabels: [], }; - const completions = await getCompletions(situation, dataProvider); + const completions = await getCompletions(situation, dataProvider, timeRange); expect(completions).toHaveLength(1); expect(completions[0].insertText).toBe('"123"'); }); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts index ff1f0490560..af0b51bddd4 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -2,6 +2,7 @@ import UFuzzy from '@leeoniya/ufuzzy'; import { languages } from 'monaco-editor'; +import { TimeRange } from '@grafana/data'; import { config } from '@grafana/runtime'; import { prometheusRegularEscape } from '../../../datasource'; @@ -160,14 +161,15 @@ function makeSelector(metricName: string | undefined, labels: Label[]): string { async function getLabelNames( metric: string | undefined, otherLabels: Label[], - dataProvider: DataProvider + dataProvider: DataProvider, + timeRange: TimeRange ): Promise { if (metric === undefined && otherLabels.length === 0) { // if there is no filtering, we have to use a special endpoint return Promise.resolve(dataProvider.getAllLabelNames()); } else { const selector = makeSelector(metric, otherLabels); - return await dataProvider.getSeriesLabels(selector, otherLabels); + return await dataProvider.getSeriesLabels(timeRange, selector, otherLabels); } } @@ -176,9 +178,10 @@ async function getLabelNamesForCompletions( suffix: string, triggerOnInsert: boolean, otherLabels: Label[], - dataProvider: DataProvider + dataProvider: DataProvider, + timeRange: TimeRange ): Promise { - const labelNames = await getLabelNames(metric, otherLabels, dataProvider); + const labelNames = await getLabelNames(metric, otherLabels, dataProvider, timeRange); return labelNames.map((text) => { const isUtf8 = !isValidLegacyName(text); return { @@ -200,31 +203,34 @@ async function getLabelNamesForCompletions( async function getLabelNamesForSelectorCompletions( metric: string | undefined, otherLabels: Label[], - dataProvider: DataProvider + dataProvider: DataProvider, + timeRange: TimeRange ): Promise { - return getLabelNamesForCompletions(metric, '=', true, otherLabels, dataProvider); + return getLabelNamesForCompletions(metric, '=', true, otherLabels, dataProvider, timeRange); } async function getLabelNamesForByCompletions( metric: string | undefined, otherLabels: Label[], - dataProvider: DataProvider + dataProvider: DataProvider, + timeRange: TimeRange ): Promise { - return getLabelNamesForCompletions(metric, '', false, otherLabels, dataProvider); + return getLabelNamesForCompletions(metric, '', false, otherLabels, dataProvider, timeRange); } async function getLabelValues( metric: string | undefined, labelName: string, otherLabels: Label[], - dataProvider: DataProvider + dataProvider: DataProvider, + timeRange: TimeRange ): Promise { if (metric === undefined && otherLabels.length === 0) { // if there is no filtering, we have to use a special endpoint - return dataProvider.getLabelValues(labelName); + return dataProvider.getLabelValues(timeRange, labelName); } else { const selector = makeSelector(metric, otherLabels); - return await dataProvider.getSeriesValues(labelName, selector); + return await dataProvider.getSeriesValues(timeRange, labelName, selector); } } @@ -233,9 +239,10 @@ async function getLabelValuesForMetricCompletions( labelName: string, betweenQuotes: boolean, otherLabels: Label[], - dataProvider: DataProvider + dataProvider: DataProvider, + timeRange: TimeRange ): Promise { - const values = await getLabelValues(metric, labelName, otherLabels, dataProvider); + const values = await getLabelValues(metric, labelName, otherLabels, dataProvider, timeRange); return values.map((text) => ({ type: 'LABEL_VALUE', label: text, @@ -248,7 +255,11 @@ function formatLabelValueForCompletion(value: string, betweenQuotes: boolean): s return betweenQuotes ? text : `"${text}"`; } -export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise { +export function getCompletions( + situation: Situation, + dataProvider: DataProvider, + timeRange: TimeRange +): Promise { switch (situation.type) { case 'IN_DURATION': return Promise.resolve(DURATION_COMPLETIONS); @@ -263,16 +274,17 @@ export function getCompletions(situation: Situation, dataProvider: DataProvider) return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]); } case 'IN_LABEL_SELECTOR_NO_LABEL_NAME': - return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider); + return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider, timeRange); case 'IN_GROUPING': - return getLabelNamesForByCompletions(situation.metricName, situation.otherLabels, dataProvider); + return getLabelNamesForByCompletions(situation.metricName, situation.otherLabels, dataProvider, timeRange); case 'IN_LABEL_SELECTOR_WITH_LABEL_NAME': return getLabelValuesForMetricCompletions( situation.metricName, situation.labelName, situation.betweenQuotes, situation.otherLabels, - dataProvider + dataProvider, + timeRange ); default: throw new NeverCaseError(situation); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts index 5108401b2fd..def57a02621 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts @@ -1,4 +1,5 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts +import { TimeRange } from '@grafana/data'; import type { Monaco, monacoTypes } from '@grafana/ui'; import { CompletionType, getCompletions } from './completions'; @@ -48,7 +49,8 @@ function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): mona export function getCompletionProvider( monaco: Monaco, - dataProvider: DataProvider + dataProvider: DataProvider, + timeRange: TimeRange ): monacoTypes.languages.CompletionItemProvider { const provideCompletionItems = ( model: monacoTypes.editor.ITextModel, @@ -84,7 +86,8 @@ export function getCompletionProvider( const offset = model.getOffsetAt(positionClone); const situation = getSituation(model.getValue(), offset); - const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]); + const completionsPromise = + situation != null ? getCompletions(situation, dataProvider, timeRange) : Promise.resolve([]); return completionsPromise.then((items) => { // monaco by-default alphabetically orders the items. diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 44e93ebb7d1..082e391ad9b 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -483,7 +483,7 @@ export class PrometheusDatasource return Promise.resolve([]); } - const timeRange = options?.range ?? this.languageProvider.timeRange ?? getDefaultTimeRange(); + const timeRange = options?.range ?? getDefaultTimeRange(); const scopedVars = { ...this.getIntervalVars(), @@ -654,6 +654,10 @@ export class PrometheusDatasource // it is used in metric_find_query.ts // and in Tempo here grafana/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx async getTagKeys(options: DataSourceGetTagKeysOptions): Promise { + if (!options.timeRange) { + options.timeRange = getDefaultTimeRange(); + } + if (config.featureToggles.promQLScope && (options?.scopes?.length ?? 0) > 0) { const suggestions = await this.languageProvider.fetchSuggestions( options.timeRange, @@ -680,7 +684,10 @@ export class PrometheusDatasource })); const expr = promQueryModeller.renderLabels(labelFilters); - let labelsIndex: Record = await this.languageProvider.fetchLabelsWithMatch(expr); + let labelsIndex: Record = await this.languageProvider.fetchLabelsWithMatch( + options.timeRange, + expr + ); // filter out already used labels return Object.keys(labelsIndex) @@ -689,7 +696,11 @@ export class PrometheusDatasource } // By implementing getTagKeys and getTagValues we add ad-hoc filters functionality - async getTagValues(options: DataSourceGetTagValuesOptions) { + async getTagValues(options: DataSourceGetTagValuesOptions): Promise { + if (!options.timeRange) { + options.timeRange = getDefaultTimeRange(); + } + const requestId = `[${this.uid}][${options.key}]`; if (config.featureToggles.promQLScope && (options?.scopes?.length ?? 0) > 0) { return ( @@ -715,7 +726,7 @@ export class PrometheusDatasource if (this.hasLabelsMatchAPISupport()) { return ( - await this.languageProvider.fetchSeriesValuesWithMatch(options.key, expr, requestId, options.timeRange) + await this.languageProvider.fetchSeriesValuesWithMatch(options.timeRange, options.key, expr, requestId) ).map((v) => ({ value: v, text: v, diff --git a/packages/grafana-prometheus/src/language_provider.test.ts b/packages/grafana-prometheus/src/language_provider.test.ts index 2b0c7edcaf3..e91845d1f00 100644 --- a/packages/grafana-prometheus/src/language_provider.test.ts +++ b/packages/grafana-prometheus/src/language_provider.test.ts @@ -110,6 +110,8 @@ describe('Language completion provider', () => { }); describe('getSeriesLabels', () => { + const timeRange = getMockTimeRange(); + it('should call labels endpoint', () => { const languageProvider = new LanguageProvider({ ...defaultDatasource, @@ -120,7 +122,7 @@ describe('Language completion provider', () => { const labelName = 'job'; const labelValue = 'grafana'; - getSeriesLabels(`{${labelName}="${labelValue}"}`, [ + getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ { name: labelName, value: labelValue, @@ -151,7 +153,7 @@ describe('Language completion provider', () => { const labelName = 'job'; const labelValue = 'grafana'; - getSeriesLabels(`{${labelName}="${labelValue}"}`, [ + getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ { name: labelName, value: labelValue, @@ -186,7 +188,7 @@ describe('Language completion provider', () => { const labelName = 'job'; const labelValue = 'grafana'; - getSeriesLabels(`{${labelName}="${labelValue}"}`, [ + getSeriesLabels(timeRange, `{${labelName}="${labelValue}"}`, [ { name: labelName, value: labelValue, @@ -217,13 +219,15 @@ describe('Language completion provider', () => { }); describe('getSeriesValues', () => { + const timeRange = getMockTimeRange(); + it('should call old series endpoint and should use match[] parameter', () => { const languageProvider = new LanguageProvider({ ...defaultDatasource, } as PrometheusDatasource); const getSeriesValues = languageProvider.getSeriesValues; const requestSpy = jest.spyOn(languageProvider, 'request'); - getSeriesValues('job', '{job="grafana"}'); + getSeriesValues(timeRange, 'job', '{job="grafana"}'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/series', @@ -246,7 +250,7 @@ describe('Language completion provider', () => { const requestSpy = jest.spyOn(languageProvider, 'request'); const labelName = 'job'; const labelValue = 'grafana'; - getSeriesValues(labelName, `{${labelName}="${labelValue}"}`); + getSeriesValues(timeRange, labelName, `{${labelName}="${labelValue}"}`); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( `/api/v1/label/${labelName}/values`, @@ -267,7 +271,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const getSeriesValues = languageProvider.getSeriesValues; const requestSpy = jest.spyOn(languageProvider, 'request'); - getSeriesValues('job', '{instance="$instance", job="grafana"}'); + getSeriesValues(timeRange, 'job', '{instance="$instance", job="grafana"}'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/series', @@ -288,7 +292,7 @@ describe('Language completion provider', () => { const timeRange = getMockTimeRange(); await languageProvider.start(timeRange); const requestSpy = jest.spyOn(languageProvider, 'request'); - await languageProvider.fetchSeries('{job="grafana"}'); + await languageProvider.fetchSeries(timeRange, '{job="grafana"}'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/series', @@ -311,7 +315,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const fetchSeriesLabels = languageProvider.fetchSeriesLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesLabels('$metric'); + fetchSeriesLabels(getMockTimeRange(), '$metric'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/series', @@ -332,7 +336,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const fetchSeriesLabels = languageProvider.fetchSeriesLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesLabels('metric-with-limit', undefined, 'none'); + fetchSeriesLabels(getMockTimeRange(), 'metric-with-limit', undefined, 'none'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/series', @@ -353,7 +357,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const fetchSeriesLabels = languageProvider.fetchSeriesLabels; const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesLabels('metric-without-limit', false, 'none'); + fetchSeriesLabels(getMockTimeRange(), 'metric-without-limit', false, 'none'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/series', @@ -619,7 +623,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const fetchLabelValues = languageProvider.fetchLabelValues; const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchLabelValues('$job'); + fetchLabelValues(getMockTimeRange(), '$job'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/label/interpolated_job/values', @@ -639,7 +643,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const fetchLabelValues = languageProvider.fetchLabelValues; const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchLabelValues('"http.status:sum"'); + fetchLabelValues(getMockTimeRange(), '"http.status:sum"'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/label/U__http_2e_status:sum/values', @@ -661,7 +665,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesValuesWithMatch('"http.status:sum"', '{__name__="a_utf8_http_requests_total"}'); + fetchSeriesValuesWithMatch(getMockTimeRange(), '"http.status:sum"', '{__name__="a_utf8_http_requests_total"}'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/label/U__http_2e_status:sum/values', @@ -681,7 +685,7 @@ describe('Language completion provider', () => { } as PrometheusDatasource); const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; const requestSpy = jest.spyOn(languageProvider, 'request'); - fetchSeriesValuesWithMatch('"http_status_sum"', '{__name__="a_utf8_http_requests_total"}'); + fetchSeriesValuesWithMatch(getMockTimeRange(), '"http_status_sum"', '{__name__="a_utf8_http_requests_total"}'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/label/http_status_sum/values', diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts index 22c427c979b..04a9f7e6e7b 100644 --- a/packages/grafana-prometheus/src/language_provider.ts +++ b/packages/grafana-prometheus/src/language_provider.ts @@ -79,7 +79,6 @@ const PREFIX_DELIMITER_REGEX = const secondsInDay = 86400; export default class PromQlLanguageProvider extends LanguageProvider { histogramMetrics: string[]; - timeRange: TimeRange; metrics: string[]; metricsMetadata?: PromMetricsMetadata; declare startTask: Promise; @@ -92,7 +91,6 @@ export default class PromQlLanguageProvider extends LanguageProvider { this.datasource = datasource; this.histogramMetrics = []; - this.timeRange = getDefaultTimeRange(); this.metrics = []; Object.assign(this, initialValues); @@ -109,7 +107,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { cleanText(s: string) { const parts = s.split(PREFIX_DELIMITER_REGEX); const last = parts.pop()!; - return last.trimLeft().replace(/"$/, '').replace(/^"/, ''); + return last.trimStart().replace(/"$/, '').replace(/^"/, ''); } get syntax() { @@ -129,16 +127,14 @@ export default class PromQlLanguageProvider extends LanguageProvider { return defaultValue; }; - start = async (timeRange?: TimeRange): Promise => { - this.timeRange = timeRange ?? getDefaultTimeRange(); - + start = async (timeRange: TimeRange = getDefaultTimeRange()): Promise => { if (this.datasource.lookupsDisabled) { return []; } - this.metrics = (await this.fetchLabelValues('__name__')) || []; + this.metrics = (await this.fetchLabelValues(timeRange, '__name__')) || []; this.histogramMetrics = processHistogramMetrics(this.metrics).sort(); - return Promise.all([this.loadMetricsMetadata(), this.fetchLabels()]); + return Promise.all([this.loadMetricsMetadata(), this.fetchLabels(timeRange)]); }; async loadMetricsMetadata() { @@ -186,15 +182,15 @@ export default class PromQlLanguageProvider extends LanguageProvider { }; } - async getSeries(selector: string, withName?: boolean): Promise> { + async getSeries(timeRange: TimeRange, selector: string, withName?: boolean): Promise> { if (this.datasource.lookupsDisabled) { return {}; } try { if (selector === EMPTY_SELECTOR) { - return await this.fetchDefaultSeries(); + return await this.fetchDefaultSeries(timeRange); } else { - return await this.fetchSeriesLabels(selector, withName, REMOVE_SERIES_LIMIT); + return await this.fetchSeriesLabels(timeRange, selector, withName, REMOVE_SERIES_LIMIT); } } catch (error) { // TODO: better error handling @@ -203,11 +199,8 @@ export default class PromQlLanguageProvider extends LanguageProvider { } } - /** - * @param key - */ - fetchLabelValues = async (key: string): Promise => { - const params = this.datasource.getAdjustedInterval(this.timeRange); + fetchLabelValues = async (range: TimeRange, key: string): Promise => { + const params = this.datasource.getAdjustedInterval(range); const interpolatedName = this.datasource.interpolateString(key); const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName)); const url = `/api/v1/label/${interpolatedAndEscapedName}/values`; @@ -215,19 +208,16 @@ export default class PromQlLanguageProvider extends LanguageProvider { return value ?? []; }; - async getLabelValues(key: string): Promise { - return await this.fetchLabelValues(key); + async getLabelValues(range: TimeRange, key: string): Promise { + return await this.fetchLabelValues(range, key); } /** * Fetches all label keys */ - fetchLabels = async (timeRange?: TimeRange, queries?: PromQuery[]): Promise => { - if (timeRange) { - this.timeRange = timeRange; - } + fetchLabels = async (timeRange: TimeRange, queries?: PromQuery[]): Promise => { let url = '/api/v1/labels'; - const timeParams = this.datasource.getAdjustedInterval(this.timeRange); + const timeParams = this.datasource.getAdjustedInterval(timeRange); this.labelFetchTs = Date.now().valueOf(); const searchParams = new URLSearchParams({ ...timeParams }); @@ -259,17 +249,15 @@ export default class PromQlLanguageProvider extends LanguageProvider { /** * Gets series values - * Function to replace old getSeries calls in a way that will provide faster endpoints for new prometheus instances, - * while maintaining backward compatability - * @param labelName - * @param selector + * Function to replace old getSeries calls in a way that will provide faster endpoints + * for new prometheus instances, while maintaining backward compatability */ - getSeriesValues = async (labelName: string, selector: string): Promise => { + getSeriesValues = async (timeRange: TimeRange, labelName: string, selector: string): Promise => { if (!this.datasource.hasLabelsMatchAPISupport()) { - const data = await this.getSeries(selector); + const data = await this.getSeries(timeRange, selector); return data[removeQuotesIfExist(labelName)] ?? []; } - return await this.fetchSeriesValuesWithMatch(labelName, selector); + return await this.fetchSeriesValuesWithMatch(timeRange, labelName, selector); }; /** @@ -280,10 +268,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { * @param requestId */ fetchSeriesValuesWithMatch = async ( + timeRange: TimeRange, name: string, match: string, - requestId?: string, - timeRange: TimeRange = this.timeRange + requestId?: string ): Promise => { const interpolatedName = name ? this.datasource.interpolateString(name) : null; const interpolatedMatch = match ? this.datasource.interpolateString(match) : null; @@ -321,16 +309,16 @@ export default class PromQlLanguageProvider extends LanguageProvider { * @param selector * @param otherLabels */ - getSeriesLabels = async (selector: string, otherLabels: Label[]): Promise => { + getSeriesLabels = async (timeRange: TimeRange, selector: string, otherLabels: Label[]): Promise => { let possibleLabelNames, data: Record; if (!this.datasource.hasLabelsMatchAPISupport()) { - data = await this.getSeries(selector); + data = await this.getSeries(timeRange, selector); possibleLabelNames = Object.keys(data); // all names from prometheus } else { // Exclude __name__ from output otherLabels.push({ name: '__name__', value: '', op: '!=' }); - data = await this.fetchSeriesLabelsMatch(selector); + data = await this.fetchSeriesLabelsMatch(timeRange, selector); possibleLabelNames = Object.keys(data); } @@ -341,31 +329,31 @@ export default class PromQlLanguageProvider extends LanguageProvider { /** * Fetch labels using the best endpoint that datasource supports. * This is cached by its args but also by the global timeRange currently selected as they can change over requested time. - * @param name - * @param withName */ - fetchLabelsWithMatch = async (name: string, withName?: boolean): Promise> => { + fetchLabelsWithMatch = async ( + timeRange: TimeRange, + name: string, + withName?: boolean + ): Promise> => { if (this.datasource.hasLabelsMatchAPISupport()) { - return this.fetchSeriesLabelsMatch(name, withName); + return this.fetchSeriesLabelsMatch(timeRange, name, withName); } else { - return this.fetchSeriesLabels(name, withName, REMOVE_SERIES_LIMIT); + return this.fetchSeriesLabels(timeRange, name, withName, REMOVE_SERIES_LIMIT); } }; /** * Fetch labels for a series using /series endpoint. This is cached by its args but also by the global timeRange currently selected as * they can change over requested time. - * @param name - * @param withName - * @param withLimit */ fetchSeriesLabels = async ( + timeRange: TimeRange, name: string, withName?: boolean, withLimit?: string ): Promise> => { const interpolatedName = this.datasource.interpolateString(name); - const range = this.datasource.getAdjustedInterval(this.timeRange); + const range = this.datasource.getAdjustedInterval(timeRange); let urlParams: UrlParamsType = { ...range, 'match[]': interpolatedName, @@ -385,12 +373,14 @@ export default class PromQlLanguageProvider extends LanguageProvider { /** * Fetch labels for a series using /labels endpoint. This is cached by its args but also by the global timeRange currently selected as * they can change over requested time. - * @param name - * @param withName */ - fetchSeriesLabelsMatch = async (name: string, withName?: boolean): Promise> => { + fetchSeriesLabelsMatch = async ( + timeRange: TimeRange, + name: string, + withName?: boolean + ): Promise> => { const interpolatedName = this.datasource.interpolateString(name); - const range = this.datasource.getAdjustedInterval(this.timeRange); + const range = this.datasource.getAdjustedInterval(timeRange); const urlParams = { ...range, 'match[]': interpolatedName, @@ -404,11 +394,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { /** * Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels. - * @param match */ - fetchSeries = async (match: string): Promise>> => { + fetchSeries = async (timeRange: TimeRange, match: string): Promise>> => { const url = '/api/v1/series'; - const range = this.datasource.getTimeRangeParams(this.timeRange); + const range = this.datasource.getTimeRangeParams(timeRange); const params = { ...range, 'match[]': match }; return await this.request(url, {}, params, this.getDefaultCacheHeaders()); }; @@ -418,8 +407,8 @@ export default class PromQlLanguageProvider extends LanguageProvider { * because we can cache more aggressively here and also we do not want to invalidate this cache the same way as in * fetchSeriesLabels. */ - fetchDefaultSeries = once(async () => { - const values = await Promise.all(DEFAULT_KEYS.map((key) => this.fetchLabelValues(key))); + fetchDefaultSeries = once(async (timeRange: TimeRange) => { + const values = await Promise.all(DEFAULT_KEYS.map((key) => this.fetchLabelValues(timeRange, key))); return DEFAULT_KEYS.reduce((acc, key, i) => ({ ...acc, [key]: values[i] }), {}); }); @@ -442,12 +431,12 @@ export default class PromQlLanguageProvider extends LanguageProvider { limit?: number, requestId?: string ): Promise => { - if (timeRange) { - this.timeRange = timeRange; + if (!timeRange) { + timeRange = getDefaultTimeRange(); } const url = '/suggestions'; - const timeParams = this.datasource.getAdjustedInterval(this.timeRange); + const timeParams = this.datasource.getAdjustedInterval(timeRange); const value = await this.request( url, [], @@ -456,7 +445,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { queries: queries?.map((q) => this.datasource.interpolateString(q.expr, { ...this.datasource.getIntervalVars(), - ...this.datasource.getRangeScopedVars(this.timeRange), + ...this.datasource.getRangeScopedVars(timeRange), }) ), scopes: scopes?.reduce((acc, scope) => { diff --git a/packages/grafana-prometheus/src/metric_find_query.ts b/packages/grafana-prometheus/src/metric_find_query.ts index 69494f0f7fc..12d17e7126f 100644 --- a/packages/grafana-prometheus/src/metric_find_query.ts +++ b/packages/grafana-prometheus/src/metric_find_query.ts @@ -1,7 +1,7 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/metric_find_query.ts import { chain, map as _map, uniq } from 'lodash'; -import { getDefaultTimeRange, MetricFindValue, TimeRange } from '@grafana/data'; +import { MetricFindValue, TimeRange } from '@grafana/data'; import { PrometheusDatasource } from './datasource'; import { getPrometheusTime } from './language_utils'; @@ -15,19 +15,15 @@ import { import { escapeForUtf8Support, isValidLegacyName } from './utf8_support'; export class PrometheusMetricFindQuery { - range: TimeRange; - constructor( private datasource: PrometheusDatasource, private query: string ) { this.datasource = datasource; this.query = query; - this.range = getDefaultTimeRange(); } process(timeRange: TimeRange): Promise { - this.range = timeRange; const labelNamesRegex = PrometheusLabelNamesRegex; const labelNamesRegexWithMatch = PrometheusLabelNamesRegexWithMatch; const labelValuesRegex = PrometheusLabelValuesRegex; @@ -38,7 +34,7 @@ export class PrometheusMetricFindQuery { if (labelNamesMatchQuery) { const selector = `{__name__=~".*${labelNamesMatchQuery[1]}.*"}`; - return this.datasource.languageProvider.getSeriesLabels(selector, []).then((results) => + return this.datasource.languageProvider.getSeriesLabels(timeRange, selector, []).then((results) => results.map((result) => ({ text: result, })) @@ -54,35 +50,35 @@ export class PrometheusMetricFindQuery { const filter = labelValuesQuery[1]; const label = labelValuesQuery[2]; if (isFilterDefined(filter)) { - return this.labelValuesQuery(label, filter); + return this.labelValuesQuery(label, timeRange, filter); } else { // Exclude the filter part of the expression because it is blank or empty - return this.labelValuesQuery(label); + return this.labelValuesQuery(label, timeRange); } } const metricNamesQuery = this.query.match(metricNamesRegex); if (metricNamesQuery) { - return this.metricNameQuery(metricNamesQuery[1]); + return this.metricNameQuery(metricNamesQuery[1], timeRange); } const queryResultQuery = this.query.match(queryResultRegex); if (queryResultQuery) { - return this.queryResultQuery(queryResultQuery[1]); + return this.queryResultQuery(queryResultQuery[1], timeRange); } // if query contains full metric name, return metric name and label list const expressions = ['label_values()', 'metrics()', 'query_result()']; if (!expressions.includes(this.query)) { - return this.metricNameAndLabelsQuery(this.query); + return this.metricNameAndLabelsQuery(this.query, timeRange); } return Promise.resolve([]); } - labelValuesQuery(label: string, metric?: string) { - const start = getPrometheusTime(this.range.from, false); - const end = getPrometheusTime(this.range.to, true); + labelValuesQuery(label: string, range: TimeRange, metric?: string) { + const start = getPrometheusTime(range.from, false); + const end = getPrometheusTime(range.to, true); const params = { ...(metric && { 'match[]': metric }), start: start.toString(), end: end.toString() }; let escapedLabel = label; @@ -118,9 +114,9 @@ export class PrometheusMetricFindQuery { } } - metricNameQuery(metricFilterPattern: string) { - const start = getPrometheusTime(this.range.from, false); - const end = getPrometheusTime(this.range.to, true); + metricNameQuery(metricFilterPattern: string, range: TimeRange) { + const start = getPrometheusTime(range.from, false); + const end = getPrometheusTime(range.to, true); const params = { start: start.toString(), end: end.toString(), @@ -143,11 +139,11 @@ export class PrometheusMetricFindQuery { }); } - queryResultQuery(query: string) { + queryResultQuery(query: string, range: TimeRange) { const url = '/api/v1/query'; const params = { query, - time: getPrometheusTime(this.range.to, true).toString(), + time: getPrometheusTime(range.to, true).toString(), }; return this.datasource.metadataRequest(url, params).then((result) => { switch (result.data.data.resultType) { @@ -182,9 +178,9 @@ export class PrometheusMetricFindQuery { }); } - metricNameAndLabelsQuery(query: string): Promise { - const start = getPrometheusTime(this.range.from, false); - const end = getPrometheusTime(this.range.to, true); + metricNameAndLabelsQuery(query: string, range: TimeRange): Promise { + const start = getPrometheusTime(range.from, false); + const end = getPrometheusTime(range.to, true); const params = { 'match[]': query, start: start.toString(), diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx index ae6353e8b1a..f6f62ffb54b 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx @@ -1,7 +1,7 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/MetricsLabelsSection.tsx import { useCallback } from 'react'; -import { SelectableValue } from '@grafana/data'; +import { SelectableValue, TimeRange } from '@grafana/data'; import { config } from '@grafana/runtime'; import { PrometheusDatasource } from '../../datasource'; @@ -22,6 +22,7 @@ export interface MetricsLabelsSectionProps { onChange: (update: PromVisualQuery) => void; variableEditor?: boolean; onBlur?: () => void; + timeRange: TimeRange; } export function MetricsLabelsSection({ @@ -30,6 +31,7 @@ export function MetricsLabelsSection({ onChange, onBlur, variableEditor, + timeRange, }: MetricsLabelsSectionProps) { // fixing the use of 'as' from refactoring // @ts-ignore @@ -63,7 +65,7 @@ export function MetricsLabelsSection({ const onGetLabelNames = async (forLabel: Partial): Promise => { // If no metric we need to use a different method if (!query.metric) { - await datasource.languageProvider.fetchLabels(); + await datasource.languageProvider.fetchLabels(timeRange); return datasource.languageProvider.getLabelKeys().map((k) => ({ value: k })); } @@ -71,7 +73,7 @@ export function MetricsLabelsSection({ labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); const expr = promQueryModeller.renderLabels(labelsToConsider); - let labelsIndex: Record = await datasource.languageProvider.fetchLabelsWithMatch(expr); + let labelsIndex: Record = await datasource.languageProvider.fetchLabelsWithMatch(timeRange, expr); // filter out already used labels return Object.keys(labelsIndex) @@ -124,7 +126,7 @@ export function MetricsLabelsSection({ if (!forLabel.label) { return Promise.resolve([]); } - const result = datasource.languageProvider.fetchSeries(promQLExpression); + const result = datasource.languageProvider.fetchSeries(timeRange, promQLExpression); const forLabelInterpolated = datasource.interpolateString(forLabel.label); return result.then((result) => { // This query returns duplicate values, scrub them out @@ -154,7 +156,7 @@ export function MetricsLabelsSection({ const requestId = `[${datasource.uid}][${query.metric}][${forLabel.label}][${forLabel.op}]`; return datasource.languageProvider - .fetchSeriesValuesWithMatch(forLabel.label, promQLExpression, requestId) + .fetchSeriesValuesWithMatch(timeRange, forLabel.label, promQLExpression, requestId) .then((response) => response.map((v) => ({ value: v, label: v }))); }; @@ -169,7 +171,7 @@ export function MetricsLabelsSection({ } // If no metric is selected, we can get the raw list of labels if (!query.metric) { - return (await datasource.languageProvider.getLabelValues(forLabel.label)).map((v) => ({ value: v })); + return (await datasource.languageProvider.getLabelValues(timeRange, forLabel.label)).map((v) => ({ value: v })); } const labelsToConsider = query.labels.filter((x) => x !== forLabel); @@ -191,8 +193,8 @@ export function MetricsLabelsSection({ }; const onGetMetrics = useCallback(() => { - return withTemplateVariableOptions(getMetrics(datasource, query)); - }, [datasource, query, withTemplateVariableOptions]); + return withTemplateVariableOptions(getMetrics(datasource, query, timeRange)); + }, [datasource, query, timeRange, withTemplateVariableOptions]); const MetricSelectComponent = config.featureToggles.prometheusUsesCombobox ? MetricCombobox : MetricSelect; @@ -229,7 +231,8 @@ export function MetricsLabelsSection({ */ async function getMetrics( datasource: PrometheusDatasource, - query: PromVisualQuery + query: PromVisualQuery, + timeRange: TimeRange ): Promise> { // Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we // don't use it with the visual builder and there is no need to run all the start() setup anyway. @@ -245,9 +248,9 @@ async function getMetrics( let metrics: string[]; if (query.labels.length > 0) { const expr = promQueryModeller.renderLabels(query.labels); - metrics = (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? []; + metrics = (await datasource.languageProvider.getSeries(timeRange, expr, true))['__name__'] ?? []; } else { - metrics = (await datasource.languageProvider.getLabelValues('__name__')) ?? []; + metrics = (await datasource.languageProvider.getLabelValues(timeRange, '__name__')) ?? []; } return metrics.map((m) => ({ diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx index 8e58f495963..372873eebb1 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx @@ -89,7 +89,7 @@ describe('PromQueryBuilder', () => { it('tries to load metrics without labels', async () => { const { languageProvider, container } = setup(); await openMetricSelect(container); - await waitFor(() => expect(languageProvider.getLabelValues).toHaveBeenCalledWith('__name__')); + await waitFor(() => expect(languageProvider.getLabelValues).toHaveBeenCalledWith(expect.anything(), '__name__')); }); it('tries to load metrics with labels', async () => { @@ -98,7 +98,13 @@ describe('PromQueryBuilder', () => { labels: [{ label: 'label_name', op: '=', value: 'label_value' }], }); await openMetricSelect(container); - await waitFor(() => expect(languageProvider.getSeries).toHaveBeenCalledWith('{label_name="label_value"}', true)); + await waitFor(() => + expect(languageProvider.getSeries).toHaveBeenCalledWith( + expect.anything(), + '{label_name="label_value"}', + expect.anything() + ) + ); }); it('tries to load variables in metric field', async () => { @@ -113,7 +119,10 @@ describe('PromQueryBuilder', () => { const { languageProvider } = setup(); await openLabelNameSelect(); await waitFor(() => - expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith('{__name__="random_metric"}') + expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith( + expect.anything(), + '{__name__="random_metric"}' + ) ); }); @@ -135,6 +144,7 @@ describe('PromQueryBuilder', () => { await openLabelNameSelect(1); await waitFor(() => expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith( + expect.anything(), '{label_name="label_value", __name__="random_metric"}' ) ); @@ -273,7 +283,10 @@ describe('PromQueryBuilder', () => { }); await openLabelNameSelect(); await waitFor(() => - expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith('{__name__="random_metric"}') + expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith( + expect.anything(), + '{__name__="random_metric"}' + ) ); }); @@ -301,6 +314,7 @@ describe('PromQueryBuilder', () => { await openLabelNameSelect(1); await waitFor(() => expect(languageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith( + expect.anything(), '{label_name="label_value", __name__="random_metric"}' ) ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx index 11c79f5932c..a7e0841f634 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { memo, useState } from 'react'; -import { DataSourceApi, PanelData } from '@grafana/data'; +import { DataSourceApi, getDefaultTimeRange, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { EditorRow } from '@grafana/plugin-ui'; @@ -43,7 +43,12 @@ export const PromQueryBuilder = memo((props) => { return ( <> - + {initHints.length ? (
Date: Fri, 21 Mar 2025 10:53:29 +0100 Subject: [PATCH 33/79] Dashboard schema: Add comment about deep copy really being deep copy. (#102586) help future me --- .../pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go | 1 + .../pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go | 1 + .../pkg/apis/dashboard/v2alpha1/dashboard_object_gen.go | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go index 5e5643192a6..f37ced365cf 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go @@ -219,6 +219,7 @@ func (o *Dashboard) Copy() resource.Object { } func (o *Dashboard) DeepCopyObject() runtime.Object { + // This really should be deep copy. If the generator tries to change it to o.Copy() it needs to be manually changed back to o.DeepCopy() and this comment added back. return o.DeepCopy() } diff --git a/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go index 8d45c64fc1a..137d7149477 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v1alpha1/dashboard_object_gen.go @@ -219,6 +219,7 @@ func (o *Dashboard) Copy() resource.Object { } func (o *Dashboard) DeepCopyObject() runtime.Object { + // This really should be deep copy. If the generator tries to change it to o.Copy() it needs to be manually changed back to o.DeepCopy() and this comment added back. return o.DeepCopy() } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_object_gen.go index 5b59fbd93aa..4437f066574 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_object_gen.go @@ -219,6 +219,7 @@ func (o *Dashboard) Copy() resource.Object { } func (o *Dashboard) DeepCopyObject() runtime.Object { + // This really should be deep copy. If the generator tries to change it to o.Copy() it needs to be manually changed back to o.DeepCopy() and this comment added back. return o.DeepCopy() } From e2737f195bcd970f782d99c7aa1cc36a947d0b80 Mon Sep 17 00:00:00 2001 From: Ieva Date: Fri, 21 Mar 2025 10:32:27 +0000 Subject: [PATCH 34/79] RBAC: Remove dashboard guardians pt 2 (#102556) * remove NewByDashboard guardian * remove unused authorizer * more cleanup * simplify canAdmin evaluation --- pkg/api/dashboard.go | 4 +- pkg/registry/apis/dashboard/authorizer.go | 91 ----------------------- pkg/registry/apis/dashboard/sub_dto.go | 34 +++------ pkg/services/guardian/guardian.go | 14 ---- pkg/services/guardian/provider.go | 4 - pkg/services/live/features/dashboard.go | 25 +++---- pkg/services/live/live.go | 1 + 7 files changed, 22 insertions(+), 151 deletions(-) delete mode 100644 pkg/registry/apis/dashboard/authorizer.go diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 704d736f371..17d37830645 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -151,9 +151,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response } deleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashScope) canDelete, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, deleteEvaluator) - adminEvaluator := accesscontrol.EvalAll( - accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsRead, dashScope), - accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsWrite, dashScope)) + adminEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsWrite, dashScope) canAdmin, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, adminEvaluator) isStarred, err := hs.isDashboardStarredByUser(c, dash.ID) diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go deleted file mode 100644 index a6e316b1584..00000000000 --- a/pkg/registry/apis/dashboard/authorizer.go +++ /dev/null @@ -1,91 +0,0 @@ -package dashboard - -import ( - "context" - - "k8s.io/apiserver/pkg/authorization/authorizer" - - claims "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/guardian" -) - -func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) authorizer.Authorizer { - return authorizer.AuthorizerFunc( - func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { - // Use the standard authorizer - if !attr.IsResourceRequest() { - return authorizer.DecisionNoOpinion, "", nil - } - - user, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "", err - } - - // Allow search and list requests - if attr.GetResource() == "search" || attr.GetName() == "" { - return authorizer.DecisionNoOpinion, "", nil - } - - ns := attr.GetNamespace() - if ns == "" { - return authorizer.DecisionDeny, "expected namespace", nil - } - - info, err := claims.ParseNamespace(attr.GetNamespace()) - if err != nil { - return authorizer.DecisionDeny, "error reading org from namespace", err - } - - // expensive path to lookup permissions for a single dashboard - dto, err := dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{ - UID: attr.GetName(), - OrgID: info.OrgID, - }) - if err != nil { - return authorizer.DecisionDeny, "error loading dashboard", err - } - - ok := false - guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user) - if err != nil { - return authorizer.DecisionDeny, "", err - } - - switch attr.GetVerb() { - case "get": - ok, err = guardian.CanView() - if !ok || err != nil { - return authorizer.DecisionDeny, "can not view dashboard", err - } - case "create": - fallthrough - case "post": - ok, err = guardian.CanSave() // vs Edit? - if !ok || err != nil { - return authorizer.DecisionDeny, "can not save dashboard", err - } - case "update": - fallthrough - case "patch": - fallthrough - case "put": - ok, err = guardian.CanEdit() // vs Save - if !ok || err != nil { - return authorizer.DecisionDeny, "can not edit dashboard", err - } - case "delete": - ok, err = guardian.CanDelete() - if !ok || err != nil { - return authorizer.DecisionDeny, "can not delete dashboard", err - } - default: - l.Info("unknown verb", "verb", attr.GetVerb()) - return authorizer.DecisionNoOpinion, "unsupported verb", nil // Unknown verb - } - return authorizer.DecisionAllow, "", nil - }) -} diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go index 5768ce906d5..32f0b787ee8 100644 --- a/pkg/registry/apis/dashboard/sub_dto.go +++ b/pkg/registry/apis/dashboard/sub_dto.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -87,7 +86,7 @@ func (r *DTOConnector) ProducesObject(verb string) interface{} { } func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - info, err := request.NamespaceInfoFrom(ctx, true) + _, err := request.NamespaceInfoFrom(ctx, true) if err != nil { return nil, err } @@ -128,33 +127,22 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob return } - // Calculate access information -- needed to help smooth transition from /api/dashboard format - dto := &dashboards.Dashboard{ - UID: name, - OrgID: info.OrgID, - ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck - } - manager, ok := obj.GetManagerProperties() - if ok && manager.Kind == utils.ManagerKindPlugin { - dto.PluginID = manager.Identity - } - - guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user) - if err != nil { - responder.Error(err) - return - } - canView, err := guardian.CanView() + dashScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(name) + evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashScope) + canView, err := r.accessControl.Evaluate(ctx, user, evaluator) if err != nil || !canView { responder.Error(fmt.Errorf("not allowed to view")) return } access := &dashboard.DashboardAccess{} - access.CanEdit, _ = guardian.CanEdit() - access.CanSave, _ = guardian.CanSave() - access.CanAdmin, _ = guardian.CanAdmin() - access.CanDelete, _ = guardian.CanDelete() + writeEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashScope) + access.CanSave, _ = r.accessControl.Evaluate(ctx, user, writeEvaluator) + access.CanEdit = access.CanSave + adminEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsWrite, dashScope) + access.CanAdmin, _ = r.accessControl.Evaluate(ctx, user, adminEvaluator) + deleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashScope) + access.CanDelete, _ = r.accessControl.Evaluate(ctx, user, deleteEvaluator) access.CanStar = user.IsIdentityType(claims.TypeUser) access.AnnotationsPermissions = &dashboard.AnnotationPermission{} diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 7100b7ffacc..58ebe2aa878 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" ) @@ -34,12 +33,6 @@ var New = func(ctx context.Context, dashId int64, orgId int64, user identity.Req panic("no guardian factory implementation provided") } -// NewByDashboard factory for creating a new dashboard guardian instance -// When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned -var NewByDashboard = func(ctx context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) { - panic("no guardian factory implementation provided") -} - // NewByFolderUID factory for creating a new folder guardian instance // When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned var NewByFolderUID = func(ctx context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) { @@ -108,13 +101,6 @@ func MockDashboardGuardian(mock *FakeDashboardGuardian) { mock.User = user return mock, nil } - NewByDashboard = func(_ context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) { - mock.OrgID = orgId - mock.DashUID = dash.UID - mock.DashID = dash.ID - mock.User = user - return mock, nil - } NewByFolderUID = func(_ context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) { mock.OrgID = orgId diff --git a/pkg/services/guardian/provider.go b/pkg/services/guardian/provider.go index 7e3902f1e5a..7be96481b51 100644 --- a/pkg/services/guardian/provider.go +++ b/pkg/services/guardian/provider.go @@ -31,10 +31,6 @@ func InitAccessControlGuardian( return NewAccessControlDashboardGuardian(ctx, cfg, dashId, user, ac, dashboardService, folderService, logger) } - NewByDashboard = func(ctx context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) { - return NewAccessControlDashboardGuardianByDashboard(ctx, cfg, dash, user, ac, dashboardService, folderService, logger) - } - NewByFolderUID = func(ctx context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) { return NewAccessControlFolderGuardianByUID(ctx, cfg, folderUID, user, ac, dashboardService, folderService) } diff --git a/pkg/services/live/features/dashboard.go b/pkg/services/live/features/dashboard.go index 2390f409fb4..286dad7c49c 100644 --- a/pkg/services/live/features/dashboard.go +++ b/pkg/services/live/features/dashboard.go @@ -10,8 +10,8 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/live/model" ) @@ -63,6 +63,7 @@ type DashboardHandler struct { ClientCount model.ChannelClientCount Store db.DB DashboardService dashboards.DashboardService + AccessControl accesscontrol.AccessControl } // GetHandlerForPath called on init @@ -77,20 +78,17 @@ func (h *DashboardHandler) OnSubscribe(ctx context.Context, user identity.Reques // make sure can view this dashboard if len(parts) == 2 && parts[0] == "uid" { query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: user.GetOrgID()} - queryResult, err := h.DashboardService.GetDashboard(ctx, &query) + _, err := h.DashboardService.GetDashboard(ctx, &query) if err != nil { logger.Error("Error getting dashboard", "query", query, "error", err) return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil } - dash := queryResult - guard, err := guardian.NewByDashboard(ctx, dash, user.GetOrgID(), user) - if err != nil { + evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(parts[1])) + canView, err := h.AccessControl.Evaluate(ctx, user, evaluator) + if err != nil || !canView { return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, err } - if canView, err := guard.CanView(); err != nil || !canView { - return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil - } return model.SubscribeReply{ Presence: true, @@ -119,19 +117,14 @@ func (h *DashboardHandler) OnPublish(ctx context.Context, requester identity.Req return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("ignore???") } query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: requester.GetOrgID()} - queryResult, err := h.DashboardService.GetDashboard(ctx, &query) + _, err = h.DashboardService.GetDashboard(ctx, &query) if err != nil { logger.Error("Unknown dashboard", "query", query) return model.PublishReply{}, backend.PublishStreamStatusNotFound, nil } - guard, err := guardian.NewByDashboard(ctx, queryResult, requester.GetOrgID(), requester) - if err != nil { - logger.Error("Failed to create guardian", "err", err) - return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error") - } - - canEdit, err := guard.CanEdit() + evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(parts[1])) + canEdit, err := h.AccessControl.Evaluate(ctx, requester, evaluator) if err != nil { return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error") } diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 35c2be26d69..64efaaa18b6 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -184,6 +184,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r ClientCount: g.ClientCount, Store: sqlStore, DashboardService: dashboardService, + AccessControl: accessControl, } g.storage = database.NewStorage(g.SQLStore, g.CacheService) g.GrafanaScope.Dashboards = dash From 6922315d7c39f9d8ed26c21af785b065276864f3 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Fri, 21 Mar 2025 11:38:43 +0100 Subject: [PATCH 35/79] SecretsManager: Add Keeper service and SQL Keeper (#102554) Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Co-authored-by: Michael Mandrus --- .../apis/secret/contracts/encryption.go | 49 ++++ pkg/registry/apis/secret/contracts/keeper.go | 32 +++ .../apis/secret/secretkeeper/secretkeeper.go | 45 ++++ .../secret/secretkeeper/secretkeeper_test.go | 35 +++ .../secret/secretkeeper/sqlkeeper/keeper.go | 92 +++++++ .../secretkeeper/sqlkeeper/keeper_test.go | 237 ++++++++++++++++++ 6 files changed, 490 insertions(+) create mode 100644 pkg/registry/apis/secret/contracts/encryption.go create mode 100644 pkg/registry/apis/secret/secretkeeper/secretkeeper.go create mode 100644 pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go create mode 100644 pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go create mode 100644 pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go diff --git a/pkg/registry/apis/secret/contracts/encryption.go b/pkg/registry/apis/secret/contracts/encryption.go new file mode 100644 index 00000000000..164143ce423 --- /dev/null +++ b/pkg/registry/apis/secret/contracts/encryption.go @@ -0,0 +1,49 @@ +package contracts + +import "context" + +// EncryptionManager is an envelope encryption service in charge of encrypting/decrypting secrets. +type EncryptionManager interface { + // Encrypt MUST NOT be used within database transactions, it may cause database locks. + // For those specific use cases where the encryption operation cannot be moved outside + // the database transaction, look at database-specific methods present at the specific + // implementation present at manager.EncryptionService. + Encrypt(ctx context.Context, namespace string, payload []byte, opt EncryptionOptions) ([]byte, error) + Decrypt(ctx context.Context, namespace string, payload []byte) ([]byte, error) + + RotateDataKeys(ctx context.Context, namespace string) error + ReEncryptDataKeys(ctx context.Context, namespace string) error +} + +type EncryptionOptions func() string + +// EncryptWithoutScope uses a root level data key for encryption (DEK), +// in other words this DEK is not bound to any specific scope (not attached to any user, org, etc.). +func EncryptWithoutScope() EncryptionOptions { + return func() string { + return "root" + } +} + +// EncryptWithScope uses a data key for encryption bound to some specific scope (i.e., user, org, etc.). +// Scope should look like "user:10", "org:1". +func EncryptWithScope(scope string) EncryptionOptions { + return func() string { + return scope + } +} + +type EncryptedValue struct { + UID string + Namespace string + EncryptedData []byte + Created int64 + Updated int64 +} + +type EncryptedValueStorage interface { + Create(ctx context.Context, namespace string, encryptedData []byte) (*EncryptedValue, error) + Update(ctx context.Context, namespace string, uid string, encryptedData []byte) error + Get(ctx context.Context, namespace string, uid string) (*EncryptedValue, error) + Delete(ctx context.Context, namespace string, uid string) error +} diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go index edd996ed4e9..2ba2cfb05dd 100644 --- a/pkg/registry/apis/secret/contracts/keeper.go +++ b/pkg/registry/apis/secret/contracts/keeper.go @@ -21,3 +21,35 @@ type KeeperMetadataStorage interface { Delete(ctx context.Context, namespace xkube.Namespace, name string) error List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.KeeperList, error) } + +// KeeperType represents the type of a Keeper. +type KeeperType string + +const ( + SQLKeeperType KeeperType = "sql" + AWSKeeperType KeeperType = "aws" + AzureKeeperType KeeperType = "azure" + GCPKeeperType KeeperType = "gcp" + HashiCorpKeeperType KeeperType = "hashicorp" +) + +// ExternalID represents either the secure value's GUID or ref (in case of external secret references). +// This is saved in the secure_value metadata storage as `external_id`. +// TODO: this does not belong in the k8s spec, but it is used by us internally. Place it somewhere appropriate. +type ExternalID string + +func (s ExternalID) String() string { + return string(s) +} + +// Keeper is the interface for secret keepers. +type Keeper interface { + Store(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, exposedValueOrRef string) (ExternalID, error) + Update(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID, exposedValueOrRef string) error + Expose(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID) (secretv0alpha1.ExposedSecureValue, error) + Delete(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID) error +} + +const ( + DefaultSQLKeeper = "kp-default-sql" +) diff --git a/pkg/registry/apis/secret/secretkeeper/secretkeeper.go b/pkg/registry/apis/secret/secretkeeper/secretkeeper.go new file mode 100644 index 00000000000..daf150d1543 --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/secretkeeper.go @@ -0,0 +1,45 @@ +package secretkeeper + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper/sqlkeeper" +) + +// Service is the interface for secret keeper services. +// This exists because OSS and Enterprise have different amounts of keepers available. +type Service interface { + GetKeepers() (map[contracts.KeeperType]contracts.Keeper, error) +} + +// OSSKeeperService is the OSS implementation of the Service interface. +type OSSKeeperService struct { + tracer tracing.Tracer + encryptionManager contracts.EncryptionManager + store contracts.EncryptedValueStorage +} + +func ProvideService( + tracer tracing.Tracer, + store contracts.EncryptedValueStorage, + encryptionManager contracts.EncryptionManager, +) (OSSKeeperService, error) { + return OSSKeeperService{ + tracer: tracer, + encryptionManager: encryptionManager, + store: store, + }, nil +} + +func (ks OSSKeeperService) GetKeepers() (map[contracts.KeeperType]contracts.Keeper, error) { + sqlKeeper, err := sqlkeeper.NewSQLKeeper(ks.tracer, ks.encryptionManager, ks.store) + if err != nil { + return nil, fmt.Errorf("failed to create sql keeper: %w", err) + } + + return map[contracts.KeeperType]contracts.Keeper{ + contracts.SQLKeeperType: sqlKeeper, + }, nil +} diff --git a/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go b/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go new file mode 100644 index 00000000000..8a89eefc226 --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go @@ -0,0 +1,35 @@ +package secretkeeper + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper/sqlkeeper" + "github.com/grafana/grafana/pkg/setting" +) + +func Test_OSSKeeperService_GetKeepers(t *testing.T) { + cfg := setting.NewCfg() + keeperService, err := setupTestService(t, cfg) + require.NoError(t, err) + + t.Run("GetKeepers should return a map with a sql keeper", func(t *testing.T) { + keeperMap, err := keeperService.GetKeepers() + require.NoError(t, err) + + assert.NotNil(t, keeperMap) + assert.Equal(t, 1, len(keeperMap)) + assert.IsType(t, &sqlkeeper.SQLKeeper{}, keeperMap[contracts.SQLKeeperType]) + }) +} + +func setupTestService(t *testing.T, cfg *setting.Cfg) (OSSKeeperService, error) { + // Initialize the keeper service + keeperService, err := ProvideService(tracing.InitializeTracerForTest(), nil, nil) + + return keeperService, err +} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go new file mode 100644 index 00000000000..7e35a2f6aa0 --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go @@ -0,0 +1,92 @@ +package sqlkeeper + +import ( + "context" + "fmt" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" +) + +type SQLKeeper struct { + tracer tracing.Tracer + encryptionManager contracts.EncryptionManager + store contracts.EncryptedValueStorage +} + +var _ contracts.Keeper = (*SQLKeeper)(nil) + +func NewSQLKeeper( + tracer tracing.Tracer, + encryptionManager contracts.EncryptionManager, + store contracts.EncryptedValueStorage, +) (*SQLKeeper, error) { + return &SQLKeeper{ + tracer: tracer, + encryptionManager: encryptionManager, + store: store, + }, nil +} + +func (s *SQLKeeper) Store(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, exposedValueOrRef string) (contracts.ExternalID, error) { + ctx, span := s.tracer.Start(ctx, "sqlKeeper.Store") + defer span.End() + + encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef), contracts.EncryptWithoutScope()) + if err != nil { + return "", fmt.Errorf("unable to encrypt value: %w", err) + } + + encryptedVal, err := s.store.Create(ctx, namespace, encryptedData) + if err != nil { + return "", fmt.Errorf("unable to store encrypted value: %w", err) + } + + return contracts.ExternalID(encryptedVal.UID), nil +} + +func (s *SQLKeeper) Expose(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID) (secretv0alpha1.ExposedSecureValue, error) { + ctx, span := s.tracer.Start(ctx, "sqlKeeper.Expose") + defer span.End() + + encryptedValue, err := s.store.Get(ctx, namespace, externalID.String()) + if err != nil { + return "", fmt.Errorf("unable to get encrypted value: %w", err) + } + + exposedBytes, err := s.encryptionManager.Decrypt(ctx, namespace, encryptedValue.EncryptedData) + if err != nil { + return "", fmt.Errorf("unable to decrypt value: %w", err) + } + + exposedValue := secretv0alpha1.NewExposedSecureValue(string(exposedBytes)) + return exposedValue, nil +} + +func (s *SQLKeeper) Delete(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID) error { + ctx, span := s.tracer.Start(ctx, "sqlKeeper.Delete") + defer span.End() + + err := s.store.Delete(ctx, namespace, externalID.String()) + if err != nil { + return fmt.Errorf("failed to delete encrypted value: %w", err) + } + return nil +} + +func (s *SQLKeeper) Update(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID, exposedValueOrRef string) error { + ctx, span := s.tracer.Start(ctx, "sqlKeeper.Update") + defer span.End() + + encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef), contracts.EncryptWithoutScope()) + if err != nil { + return fmt.Errorf("unable to encrypt value: %w", err) + } + + err = s.store.Update(ctx, namespace, externalID.String(), encryptedData) + if err != nil { + return fmt.Errorf("failed to update encrypted value: %w", err) + } + return nil +} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go new file mode 100644 index 00000000000..a304c6cdc4c --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go @@ -0,0 +1,237 @@ +package sqlkeeper + +import ( + "context" + "encoding/base64" + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/setting" +) + +// Make this a `TestIntegration` once we have the real storage implementation +func Test_SQLKeeperSetup(t *testing.T) { + ctx := context.Background() + namespace1 := "namespace1" + namespace2 := "namespace2" + plaintext1 := "very secret string in namespace 1" + plaintext2 := "very secret string in namespace 2" + nonExistentID := contracts.ExternalID("non existent") + + cfg := setting.NewCfg() + + sqlKeeper, err := setupTestService(t, cfg) + require.NoError(t, err) + require.NotNil(t, sqlKeeper) + + t.Run("storing an encrypted value returns no error", func(t *testing.T) { + externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalId1) + + externalId2, err := sqlKeeper.Store(ctx, nil, namespace2, plaintext2) + require.NoError(t, err) + require.NotEmpty(t, externalId2) + + t.Run("expose the encrypted value from existing namespace", func(t *testing.T) { + exposedVal1, err := sqlKeeper.Expose(ctx, nil, namespace1, externalId1) + require.NoError(t, err) + require.NotNil(t, exposedVal1) + assert.Equal(t, plaintext1, exposedVal1.DangerouslyExposeAndConsumeValue()) + + exposedVal2, err := sqlKeeper.Expose(ctx, nil, namespace2, externalId2) + require.NoError(t, err) + require.NotNil(t, exposedVal2) + assert.Equal(t, plaintext2, exposedVal2.DangerouslyExposeAndConsumeValue()) + }) + + t.Run("expose encrypted value from different namespace returns error", func(t *testing.T) { + exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace2, externalId1) + require.Error(t, err) + assert.Empty(t, exposedVal) + + exposedVal, err = sqlKeeper.Expose(ctx, nil, namespace1, externalId2) + require.Error(t, err) + assert.Empty(t, exposedVal) + }) + }) + + t.Run("storing same value in same namespace returns no error", func(t *testing.T) { + externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalId1) + + externalId2, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalId2) + + assert.NotEqual(t, externalId1, externalId2) + }) + + t.Run("storing same value in different namespace returns no error", func(t *testing.T) { + externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalId1) + + externalId2, err := sqlKeeper.Store(ctx, nil, namespace2, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalId2) + + assert.NotEqual(t, externalId1, externalId2) + }) + + t.Run("exposing non existing values returns error", func(t *testing.T) { + exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, nonExistentID) + require.Error(t, err) + assert.Empty(t, exposedVal) + }) + + t.Run("deleting an existing encrypted value does not return error", func(t *testing.T) { + externalID, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalID) + + exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, externalID) + require.NoError(t, err) + assert.NotNil(t, exposedVal) + assert.Equal(t, plaintext1, exposedVal.DangerouslyExposeAndConsumeValue()) + + err = sqlKeeper.Delete(ctx, nil, namespace1, externalID) + require.NoError(t, err) + }) + + t.Run("deleting an non existing encrypted value does not return error", func(t *testing.T) { + err = sqlKeeper.Delete(ctx, nil, namespace1, nonExistentID) + require.NoError(t, err) + }) + + t.Run("updating an existent encrypted value returns no error", func(t *testing.T) { + externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalId1) + + err = sqlKeeper.Update(ctx, nil, namespace1, externalId1, plaintext2) + require.NoError(t, err) + + exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, externalId1) + require.NoError(t, err) + assert.NotNil(t, exposedVal) + assert.Equal(t, plaintext2, exposedVal.DangerouslyExposeAndConsumeValue()) + }) + + t.Run("updating a non existent encrypted value returns error", func(t *testing.T) { + externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1) + require.NoError(t, err) + require.NotEmpty(t, externalId1) + + err = sqlKeeper.Update(ctx, nil, namespace1, nonExistentID, plaintext2) + require.Error(t, err) + }) +} + +func setupTestService(t *testing.T, cfg *setting.Cfg) (*SQLKeeper, error) { + // Initialize the encryption manager with in-memory implementation + encMgr := &inMemoryEncryptionManager{} + + // Initialize encrypted value storage with in-memory implementation + encValueStore := newInMemoryEncryptedValueStorage() + + // Initialize the SQLKeeper + sqlKeeper, err := NewSQLKeeper(tracing.InitializeTracerForTest(), encMgr, encValueStore) + + return sqlKeeper, err +} + +// While we don't have the real implementation, use an in-memory one +type inMemoryEncryptionManager struct{} + +func (m *inMemoryEncryptionManager) Encrypt(_ context.Context, _ string, value []byte, _ contracts.EncryptionOptions) ([]byte, error) { + return []byte(base64.StdEncoding.EncodeToString(value)), nil +} + +func (m *inMemoryEncryptionManager) Decrypt(_ context.Context, _ string, value []byte) ([]byte, error) { + return base64.StdEncoding.DecodeString(string(value)) +} + +func (m *inMemoryEncryptionManager) ReEncryptDataKeys(_ context.Context, _ string) error { + return nil +} + +func (m *inMemoryEncryptionManager) RotateDataKeys(_ context.Context, _ string) error { + return nil +} + +// While we don't have the real implementation, use an in-memory one +type inMemoryEncryptedValueStorage struct { + mu sync.RWMutex + store map[string]*contracts.EncryptedValue +} + +func newInMemoryEncryptedValueStorage() *inMemoryEncryptedValueStorage { + return &inMemoryEncryptedValueStorage{ + store: make(map[string]*contracts.EncryptedValue), + } +} + +func (m *inMemoryEncryptedValueStorage) Create(_ context.Context, namespace string, encryptedData []byte) (*contracts.EncryptedValue, error) { + m.mu.Lock() + defer m.mu.Unlock() + + uid := fmt.Sprintf("%d", len(m.store)+1) // Generate simple incremental IDs + encValue := &contracts.EncryptedValue{ + UID: uid, + Namespace: namespace, + EncryptedData: encryptedData, + Created: 1, // Dummy timestamp + Updated: 1, // Dummy timestamp + } + + compositeKey := namespace + ":" + uid + m.store[compositeKey] = encValue + + return encValue, nil +} + +func (m *inMemoryEncryptedValueStorage) Get(_ context.Context, namespace string, uid string) (*contracts.EncryptedValue, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + compositeKey := namespace + ":" + uid + encValue, exists := m.store[compositeKey] + if !exists { + return nil, fmt.Errorf("value not found for namespace %s and uid %s", namespace, uid) + } + + return encValue, nil +} + +func (m *inMemoryEncryptedValueStorage) Delete(_ context.Context, namespace string, uid string) error { + m.mu.Lock() + defer m.mu.Unlock() + + compositeKey := namespace + ":" + uid + delete(m.store, compositeKey) + + return nil +} + +func (m *inMemoryEncryptedValueStorage) Update(_ context.Context, namespace string, uid string, encryptedData []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + + compositeKey := namespace + ":" + uid + encValue, exists := m.store[compositeKey] + if !exists { + return fmt.Errorf("value not found for namespace %s and uid %s", namespace, uid) + } + + encValue.EncryptedData = encryptedData + encValue.Updated = 2 // Update timestamp + return nil +} From 73436e3d55f384a85417ab47a8af56510b710d8e Mon Sep 17 00:00:00 2001 From: Ieva Date: Fri, 21 Mar 2025 10:44:16 +0000 Subject: [PATCH 36/79] RBAC: Remove dashboard guardians pt 3 (#102558) * remove usage of New dashboard guardian * fix tests --- pkg/api/annotations.go | 29 ++++++++-------------- pkg/api/annotations_test.go | 39 +++++++++++++++--------------- pkg/api/dashboard_snapshot.go | 24 +++++++----------- pkg/api/dashboard_snapshot_test.go | 6 ++--- pkg/api/folder_test.go | 9 +++++++ 5 files changed, 51 insertions(+), 56 deletions(-) diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 4720e25e91f..5873504d7c9 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -278,7 +277,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *contextmodel.ReqContext) response.Resp } if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { - if canSave, err := hs.canSaveAnnotation(c, annotation); err != nil || !canSave { + if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave { return dashboardGuardianResponse(err) } } @@ -336,7 +335,7 @@ func (hs *HTTPServer) PatchAnnotation(c *contextmodel.ReqContext) response.Respo } if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) { - if canSave, err := hs.canSaveAnnotation(c, annotation); err != nil || !canSave { + if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave { return dashboardGuardianResponse(err) } } @@ -502,7 +501,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response. return resp } - if canSave, err := hs.canSaveAnnotation(c, annotation); err != nil || !canSave { + if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave { return dashboardGuardianResponse(err) } } @@ -518,25 +517,17 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response. return response.Success("Annotation deleted") } -func (hs *HTTPServer) canSaveAnnotation(c *contextmodel.ReqContext, annotation *annotations.ItemDTO) (bool, error) { +func (hs *HTTPServer) canSaveAnnotation(c *contextmodel.ReqContext, ac accesscontrol.AccessControl, annotation *annotations.ItemDTO) (bool, error) { if annotation.GetType() == annotations.Dashboard { - return canEditDashboard(c, annotation.DashboardID) + return canEditDashboard(c, ac, annotation.DashboardID) } else { return true, nil } } -func canEditDashboard(c *contextmodel.ReqContext, dashboardID int64) (bool, error) { - guard, err := guardian.New(c.Req.Context(), dashboardID, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - return false, err - } - - if canEdit, err := guard.CanEdit(); err != nil || !canEdit { - return false, err - } - - return true, nil +func canEditDashboard(c *contextmodel.ReqContext, ac accesscontrol.AccessControl, dashboardID int64) (bool, error) { + evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(dashboardID, 10))) + return ac.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) } func findAnnotationByID(ctx context.Context, repo annotations.Repository, annotationID int64, user *user.SignedInUser) (*annotations.ItemDTO, response.Response) { @@ -680,7 +671,7 @@ func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardI return canSave, err } - return canEditDashboard(c, dashboardId) + return canEditDashboard(c, hs.AccessControl, dashboardId) } else { // organization annotations evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, accesscontrol.ScopeAnnotationsTypeOrganization) return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) @@ -708,7 +699,7 @@ func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashb return false, err } - canSave, err = canEditDashboard(c, dashboardID) + canSave, err = canEditDashboard(c, hs.AccessControl, dashboardID) if err != nil || !canSave { return false, err } diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 83e11eb91cf..fa4f119f13b 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/foldertest" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web/webtest" ) @@ -110,7 +109,10 @@ func TestAPI_Annotations(t *testing.T) { path: "/api/annotations/2", method: http.MethodPut, expectedCode: http.StatusOK, - permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}}, + permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + }, }, { desc: "should not be able to update dashboard annotation without correct permission", @@ -162,7 +164,10 @@ func TestAPI_Annotations(t *testing.T) { path: "/api/annotations/2", method: http.MethodPatch, expectedCode: http.StatusOK, - permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}}, + permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + }, }, { desc: "should not be able to patch dashboard annotation without correct permission", @@ -215,7 +220,10 @@ func TestAPI_Annotations(t *testing.T) { method: http.MethodPost, body: "{\"dashboardId\": 2,\"text\": \"test\"}", expectedCode: http.StatusOK, - permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsCreate, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}}, + permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionAnnotationsCreate, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + }, }, { desc: "should not be able to create dashboard annotation without correct permission", @@ -273,7 +281,10 @@ func TestAPI_Annotations(t *testing.T) { path: "/api/annotations/2", method: http.MethodDelete, expectedCode: http.StatusOK, - permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}}, + permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + }, }, { desc: "should not be able to delete dashboard annotation without correct permission", @@ -341,7 +352,10 @@ func TestAPI_Annotations(t *testing.T) { body: "{\"dashboardId\": 2, \"panelId\": 1}", method: http.MethodPost, expectedCode: http.StatusOK, - permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}}, + permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}, + {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll}, + }, }, { desc: "should not be able to mass delete dashboard annotations without correct permission", @@ -382,10 +396,6 @@ func TestAPI_Annotations(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - // Don't need access to dashboards if annotationPermissionUpdate is enabled - if len(tt.featureFlags) == 0 { - setUpRBACGuardian(t) - } server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = setting.NewCfg() repo := annotationstest.NewFakeAnnotationsRepo() @@ -518,12 +528,3 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) { }) } } - -func setUpRBACGuardian(t *testing.T) { - origNewGuardian := guardian.New - t.Cleanup(func() { - guardian.New = origNewGuardian - }) - - guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanEditValue: true, CanViewValue: true}) -} diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index b583c550da7..4ebe1e8708f 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "time" "github.com/grafana/grafana/pkg/api/dtos" @@ -17,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errhttp" "github.com/grafana/grafana/pkg/web" @@ -226,21 +226,15 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon dashboardID := queryResult.Dashboard.Get("id").MustInt64() if dashboardID != 0 { - g, err := guardian.New(c.Req.Context(), dashboardID, c.SignedInUser.GetOrgID(), c.SignedInUser) - if err != nil { - if !errors.Is(err, dashboards.ErrDashboardNotFound) { - return response.Err(err) - } - } else { - canEdit, err := g.CanEdit() - // check for permissions only if the dashboard is found - if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { - return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err) - } + evaluator := ac.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(dashboardID, 10))) + canEdit, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator) + // check for permissions only if the dashboard is found + if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { + return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err) + } - if !canEdit && queryResult.UserID != c.SignedInUser.UserID && !errors.Is(err, dashboards.ErrDashboardNotFound) { - return response.Error(http.StatusForbidden, "Access denied to this snapshot", nil) - } + if !canEdit && queryResult.UserID != c.SignedInUser.UserID && !errors.Is(err, dashboards.ErrDashboardNotFound) { + return response.Error(http.StatusForbidden, "Access denied to this snapshot", nil) } } diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index 24dcd3ef4ec..01d9e6ce4d0 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -15,13 +15,12 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db/dbtest" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -41,7 +40,7 @@ func TestHTTPServer_DeleteDashboardSnapshot(t *testing.T) { hs.DashboardService = svc hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) - guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger()) + hs.AccessControl.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(svc, nil)) }) } @@ -378,6 +377,7 @@ func buildHttpServer(d dashboardsnapshots.Service, snapshotEnabled bool) *HTTPSe Cfg: &setting.Cfg{ SnapshotEnabled: snapshotEnabled, }, + AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, } return hs } diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 840ae125594..96e8b75a0ec 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -768,3 +768,12 @@ func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) { }) } } + +func setUpRBACGuardian(t *testing.T) { + origNewGuardian := guardian.New + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanEditValue: true, CanViewValue: true}) +} From c11a37eabb09dbf09d6080029f99fafda7403077 Mon Sep 17 00:00:00 2001 From: Collin Fingar Date: Fri, 21 Mar 2025 08:26:32 -0400 Subject: [PATCH 37/79] Combobox: Fix option truncation w/ autoPlacement (#102568) * Combobox: Fix option truncation w/ autoPlacement * Add back in boundary property --- .../src/components/Combobox/useComboboxFloat.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts index f5803afdf2e..f2d5ca7de20 100644 --- a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts +++ b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts @@ -1,4 +1,4 @@ -import { autoUpdate, flip, size, useFloating } from '@floating-ui/react'; +import { autoUpdate, autoPlacement, size, useFloating } from '@floating-ui/react'; import { useMemo, useRef, useState } from 'react'; import { measureText } from '../../utils'; @@ -31,10 +31,11 @@ export const useComboboxFloat = (items: Array>, // the order of middleware is important! const middleware = [ - flip({ - // see https://floating-ui.com/docs/flip#combining-with-shift - crossAxis: true, + autoPlacement({ + // see https://floating-ui.com/docs/autoplacement + allowedPlacements: ['bottom-start', 'bottom-end', 'top-start', 'top-end'], boundary: document.body, + crossAxis: true, }), size({ apply({ availableWidth, availableHeight }) { From 20e171968e3068df5cb4409d8097f79ec98eb8fe Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Fri, 21 Mar 2025 13:50:45 +0100 Subject: [PATCH 38/79] Advisor: Avoid returning an error when creating initial resources (#102545) --- .../pkg/app/checkscheduler/checkscheduler.go | 25 ++++++---- .../app/checkscheduler/checkscheduler_test.go | 46 +++++++++++------- .../checktyperegisterer.go | 48 +++++++++++++++---- .../checktyperegisterer_test.go | 6 ++- 4 files changed, 88 insertions(+), 37 deletions(-) diff --git a/apps/advisor/pkg/app/checkscheduler/checkscheduler.go b/apps/advisor/pkg/app/checkscheduler/checkscheduler.go index 2bcb8d43f19..cdca40cbd77 100644 --- a/apps/advisor/pkg/app/checkscheduler/checkscheduler.go +++ b/apps/advisor/pkg/app/checkscheduler/checkscheduler.go @@ -14,6 +14,7 @@ import ( advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" "github.com/grafana/grafana/apps/advisor/pkg/app/checks" + "github.com/grafana/grafana/pkg/infra/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/klog/v2" ) @@ -30,6 +31,7 @@ type Runner struct { evaluationInterval time.Duration maxHistory int namespace string + log log.Logger } // NewRunner creates a new Runner. @@ -66,22 +68,25 @@ func New(cfg app.Config) (app.Runnable, error) { evaluationInterval: evalInterval, maxHistory: maxHistory, namespace: namespace, + log: log.New("advisor.checkscheduler"), }, nil } func (r *Runner) Run(ctx context.Context) error { lastCreated, err := r.checkLastCreated(ctx) if err != nil { - return err - } - - // do an initial creation if necessary - if lastCreated.IsZero() { - err = r.createChecks(ctx) - if err != nil { - klog.Error("Error creating new check reports", "error", err) - } else { - lastCreated = time.Now() + r.log.Error("Error getting last check creation time", "error", err) + // Wait for interval to create the next scheduled check + lastCreated = time.Now() + } else { + // do an initial creation if necessary + if lastCreated.IsZero() { + err = r.createChecks(ctx) + if err != nil { + klog.Error("Error creating new check reports", "error", err) + } else { + lastCreated = time.Now() + } } } diff --git a/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go b/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go index 4fd5876b9ff..23ce113df4a 100644 --- a/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go +++ b/apps/advisor/pkg/app/checkscheduler/checkscheduler_test.go @@ -11,28 +11,35 @@ import ( "github.com/grafana/grafana-app-sdk/resource" advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" "github.com/grafana/grafana/apps/advisor/pkg/app/checks" + "github.com/grafana/grafana/pkg/infra/log" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func TestRunner_Run_ErrorOnList(t *testing.T) { - mockCheckService := &MockCheckService{} - mockClient := &MockClient{ - listFunc: func(ctx context.Context, namespace string, options resource.ListOptions) (resource.ListObject, error) { - return nil, errors.New("list error") - }, - createFunc: func(ctx context.Context, id resource.Identifier, obj resource.Object, opts resource.CreateOptions) (resource.Object, error) { - return &advisorv0alpha1.Check{}, nil - }, - } +func TestRunner_Run(t *testing.T) { + t.Run("does not crash when error on list", func(t *testing.T) { + mockCheckService := &MockCheckService{} + mockClient := &MockClient{ + listFunc: func(ctx context.Context, namespace string, options resource.ListOptions) (resource.ListObject, error) { + return nil, errors.New("list error") + }, + createFunc: func(ctx context.Context, id resource.Identifier, obj resource.Object, opts resource.CreateOptions) (resource.Object, error) { + return &advisorv0alpha1.Check{}, nil + }, + } - runner := &Runner{ - checkRegistry: mockCheckService, - client: mockClient, - } + runner := &Runner{ + checkRegistry: mockCheckService, + client: mockClient, + log: log.NewNopLogger(), + evaluationInterval: 1 * time.Hour, + } - err := runner.Run(context.Background()) - assert.Error(t, err) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := runner.Run(ctx) + assert.ErrorAs(t, err, &context.Canceled) + }) } func TestRunner_checkLastCreated_ErrorOnList(t *testing.T) { @@ -44,6 +51,7 @@ func TestRunner_checkLastCreated_ErrorOnList(t *testing.T) { runner := &Runner{ client: mockClient, + log: log.NewNopLogger(), } lastCreated, err := runner.checkLastCreated(context.Background()) @@ -68,6 +76,7 @@ func TestRunner_createChecks_ErrorOnCreate(t *testing.T) { runner := &Runner{ checkRegistry: mockCheckService, client: mockClient, + log: log.NewNopLogger(), } err := runner.createChecks(context.Background()) @@ -91,6 +100,7 @@ func TestRunner_createChecks_Success(t *testing.T) { runner := &Runner{ checkRegistry: mockCheckService, client: mockClient, + log: log.NewNopLogger(), } err := runner.createChecks(context.Background()) @@ -106,6 +116,7 @@ func TestRunner_cleanupChecks_ErrorOnList(t *testing.T) { runner := &Runner{ client: mockClient, + log: log.NewNopLogger(), } err := runner.cleanupChecks(context.Background()) @@ -126,6 +137,7 @@ func TestRunner_cleanupChecks_WithinMax(t *testing.T) { runner := &Runner{ client: mockClient, + log: log.NewNopLogger(), } err := runner.cleanupChecks(context.Background()) @@ -155,6 +167,7 @@ func TestRunner_cleanupChecks_ErrorOnDelete(t *testing.T) { runner := &Runner{ client: mockClient, maxHistory: defaultMaxHistory, + log: log.NewNopLogger(), } err := runner.cleanupChecks(context.Background()) assert.ErrorContains(t, err, "delete error") @@ -190,6 +203,7 @@ func TestRunner_cleanupChecks_Success(t *testing.T) { runner := &Runner{ client: mockClient, maxHistory: defaultMaxHistory, + log: log.NewNopLogger(), } err := runner.cleanupChecks(context.Background()) assert.NoError(t, err) diff --git a/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go b/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go index 2ed38e51ecb..3e563b3f88f 100644 --- a/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go +++ b/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer.go @@ -3,6 +3,7 @@ package checktyperegisterer import ( "context" "fmt" + "time" "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/k8s" @@ -10,6 +11,7 @@ import ( advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" "github.com/grafana/grafana/apps/advisor/pkg/app/checks" + "github.com/grafana/grafana/pkg/infra/log" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -21,6 +23,9 @@ type Runner struct { checkRegistry checkregistry.CheckService client resource.Client namespace string + log log.Logger + retryAttempts int + retryDelay time.Duration } // NewRunner creates a new Runner. @@ -47,9 +52,32 @@ func New(cfg app.Config) (app.Runnable, error) { checkRegistry: checkRegistry, client: client, namespace: namespace, + log: log.New("advisor.checktyperegisterer"), + retryAttempts: 3, + retryDelay: time.Second * 5, }, nil } +func (r *Runner) createOrUpdate(ctx context.Context, obj resource.Object) error { + id := obj.GetStaticMetadata().Identifier() + _, err := r.client.Create(ctx, id, obj, resource.CreateOptions{}) + if err != nil { + if errors.IsAlreadyExists(err) { + // Already exists, update + r.log.Debug("Check type already exists, updating", "identifier", id) + _, err = r.client.Update(ctx, id, obj, resource.UpdateOptions{}) + if err != nil { + // Ignore the error, it's probably due to a race condition + r.log.Error("Error updating check type", "error", err) + } + return nil + } + return err + } + r.log.Debug("Check type registered successfully", "identifier", id) + return nil +} + func (r *Runner) Run(ctx context.Context) error { for _, t := range r.checkRegistry.Checks() { steps := t.Steps() @@ -72,19 +100,19 @@ func (r *Runner) Run(ctx context.Context) error { Steps: stepTypes, }, } - id := obj.GetStaticMetadata().Identifier() - _, err := r.client.Create(ctx, id, obj, resource.CreateOptions{}) - if err != nil { - if errors.IsAlreadyExists(err) { - // Already exists, update - _, err = r.client.Update(ctx, id, obj, resource.UpdateOptions{}) - if err != nil { - return err + for i := 0; i < r.retryAttempts; i++ { + err := r.createOrUpdate(ctx, obj) + if err != nil { + r.log.Error("Error creating check type, retrying", "error", err, "attempt", i+1) + if i == r.retryAttempts-1 { + r.log.Error("Unable to register check type") } else { - continue + time.Sleep(r.retryDelay) } + continue } - return err + r.log.Debug("Check type registered successfully", "check_type", t.ID()) + break } } return nil diff --git a/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer_test.go b/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer_test.go index 97fc7742504..4d4bab9adf3 100644 --- a/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer_test.go +++ b/apps/advisor/pkg/app/checktyperegisterer/checktyperegisterer_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana-app-sdk/resource" advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" "github.com/grafana/grafana/apps/advisor/pkg/app/checks" + "github.com/grafana/grafana/pkg/infra/log" k8sErrs "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -117,7 +118,10 @@ func TestCheckTypesRegisterer_Run(t *testing.T) { createFunc: tt.createFunc, updateFunc: tt.updateFunc, }, - namespace: "custom-namespace", + namespace: "custom-namespace", + log: log.New("test"), + retryAttempts: 1, + retryDelay: 0, } err := r.Run(context.Background()) if err != nil { From 3339251b573066f0b60e22819094a711ae821da6 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Fri, 21 Mar 2025 10:17:34 -0300 Subject: [PATCH 39/79] TimeRangeInput: Fix compatibility with Drawer (#101709) --- .../DateTimePickers/TimeRangeInput.tsx | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx index d07788bf97e..7ef005c42c6 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx @@ -1,8 +1,7 @@ import { css, cx } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; +import { useDismiss, useFloating, useInteractions } from '@floating-ui/react'; import { FocusScope } from '@react-aria/focus'; -import { useOverlay } from '@react-aria/overlays'; -import { createRef, FormEvent, MouseEvent, useState } from 'react'; +import { FormEvent, MouseEvent, useState } from 'react'; import { dateTime, getDefaultTimeRange, GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -79,22 +78,21 @@ export const TimeRangeInput = ({ onChange({ from, to, raw: { from, to } }); }; - const overlayRef = createRef(); - const buttonRef = createRef(); + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement: 'bottom-start', + strategy: 'fixed', + }); - const { dialogProps } = useDialog({}, overlayRef); - - const { overlayProps } = useOverlay( - { - onClose, - isDismissable: true, - isOpen, - shouldCloseOnInteractOutside: (element) => { - return !buttonRef.current?.contains(element); - }, + const dismiss = useDismiss(context, { + bubbles: { + outsidePress: false, }, - overlayRef - ); + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); + return (
{isOpen && ( -
+
{ marginLeft: 0, position: 'absolute', top: '116%', - zIndex: theme.zIndex.dropdown, + zIndex: theme.zIndex.modal, }), pickerInput: cx( inputStyles.input, From ea89499209b632769b61ed65fbac5e4893383187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20=C5=A0tibran=C3=BD?= Date: Fri, 21 Mar 2025 14:18:22 +0100 Subject: [PATCH 40/79] Fix failing integration tests in folderimpl package when running on Spanner. (#102602) * Fix failing integration tests in folderimpl package when running on Spanner. * Add comments. --- pkg/services/dashboards/database/database.go | 5 +++-- pkg/services/folder/folderimpl/sqlstore.go | 6 +++--- pkg/services/libraryelements/model/model.go | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index d5972f16bc3..032d574b07f 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "time" @@ -673,7 +674,7 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, {SQL: "DELETE FROM dashboard_tag WHERE dashboard_uid = ? AND org_id = ?", args: []any{dashboard.UID, dashboard.OrgID}}, {SQL: "DELETE FROM star WHERE dashboard_id = ? ", args: []any{dashboard.ID}}, {SQL: "DELETE FROM dashboard WHERE id = ?", args: []any{dashboard.ID}}, - {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{dashboard.ID}}, + {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{strconv.FormatInt(dashboard.ID, 10)}}, // Column has TEXT type. {SQL: "DELETE FROM dashboard_version WHERE dashboard_id = ?", args: []any{dashboard.ID}}, {SQL: "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", args: []any{dashboard.ID}}, {SQL: "DELETE FROM dashboard_acl WHERE dashboard_id = ?", args: []any{dashboard.ID}}, @@ -737,7 +738,7 @@ func (d *dashboardStore) CleanupAfterDelete(ctx context.Context, cmd *dashboards sqlStatements := []statement{ {SQL: "DELETE FROM dashboard_tag WHERE dashboard_uid = ? AND org_id = ?", args: []any{cmd.UID, cmd.OrgID}}, {SQL: "DELETE FROM star WHERE dashboard_uid = ? AND org_id = ?", args: []any{cmd.UID, cmd.OrgID}}, - {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{cmd.ID}}, + {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{strconv.FormatInt(cmd.ID, 10)}}, // Column has TEXT type. {SQL: "DELETE FROM dashboard_version WHERE dashboard_id = ?", args: []any{cmd.ID}}, {SQL: "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", args: []any{cmd.ID}}, {SQL: "DELETE FROM dashboard_acl WHERE dashboard_id = ?", args: []any{cmd.ID}}, diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 14601344a82..20c17c324b5 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -607,9 +607,9 @@ func (ss *FolderStoreImpl) GetDescendants(ctx context.Context, orgID int64, ance } func getFullpathSQL(dialect migrator.Dialect) string { - escaped := "\\/" - if dialect.DriverName() == migrator.MySQL { - escaped = "\\\\/" + escaped := `\/` + if dialect.DriverName() == migrator.MySQL || dialect.DriverName() == migrator.Spanner { + escaped = `\\/` } concatCols := make([]string, 0, folder.MaxNestedFolderDepth) concatCols = append(concatCols, fmt.Sprintf("COALESCE(REPLACE(f0.title, '/', '%s'), '')", escaped)) diff --git a/pkg/services/libraryelements/model/model.go b/pkg/services/libraryelements/model/model.go index 0828e9578f6..0189d785fd5 100644 --- a/pkg/services/libraryelements/model/model.go +++ b/pkg/services/libraryelements/model/model.go @@ -26,7 +26,7 @@ type LibraryElement struct { Kind int64 Type string Description string - Model json.RawMessage + Model json.RawMessage `xorm:"TEXT"` // Column is defined as TEXT in `library_element`. Version int64 Created time.Time From aeca9a80a4ea08fa6a692e9ea11dc0281d3ab043 Mon Sep 17 00:00:00 2001 From: Quentin Bisson Date: Fri, 21 Mar 2025 14:18:53 +0100 Subject: [PATCH 41/79] JWT: Add org role mapping support to the JWT provider (#101584) * add org role mapping to the jwt provider * Fix indentation for OrgMapping assignment * add-test * fix linting * add org_attribute_path * fix test * update doc * update doc * Update pkg/services/authn/clients/jwt.go * Update docs --------- Co-authored-by: Mihaly Gyongyosi --- conf/defaults.ini | 4 +- conf/sample.ini | 6 +- .../configure-authentication/jwt/index.md | 50 ++++++- pkg/services/authn/authnimpl/registration.go | 4 +- pkg/services/authn/clients/jwt.go | 69 +++++---- pkg/services/authn/clients/jwt_test.go | 137 +++++++++++++++++- pkg/setting/setting_jwt.go | 10 +- 7 files changed, 234 insertions(+), 46 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 55378a7f6ee..a4679a13a6f 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -932,6 +932,8 @@ key_file = key_id = role_attribute_path = role_attribute_strict = false +org_attribute_path = +org_mapping = groups_attribute_path = auto_sign_up = false url_login = false @@ -1543,7 +1545,7 @@ timeout = 10s # Default data source UID to write to if not specified in the rule definition. # Only has effect if the grafanaManagedRecordRulesDatasources feature toggle is enabled. -default_datasource_uid = +default_datasource_uid = # Optional custom headers to include in recording rule write requests. [recording_rules.custom_headers] diff --git a/conf/sample.ini b/conf/sample.ini index ea2cb9a00df..f06e41efed9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -132,7 +132,7 @@ # Set to true or false to enable or disable high availability mode. # When it's set to false some functions will be simplified and only run in-process # instead of relying on the database. -# +# # Only set it to false if you run only a single instance of Grafana. ;high_availability = true @@ -901,6 +901,8 @@ ;key_id = some-key-id ;role_attribute_path = ;role_attribute_strict = false +;org_attribute_path = +;org_mapping = ;groups_attribute_path = ;auto_sign_up = false ;url_login = false @@ -1525,7 +1527,7 @@ timeout = 30s # Default data source UID to write to if not specified in the rule definition. # Only has effect if the grafanaManagedRecordRulesDatasources feature toggle is enabled. -default_datasource_uid = +default_datasource_uid = # Optional custom headers to include in recording rule write requests. [recording_rules.custom_headers] diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md index 29dfa1f17ae..26ac14c8188 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md @@ -202,13 +202,19 @@ Grafana checks for the presence of a role using the [JMESPath](http://jmespath.o To assign the role to a specific organization include the `X-Grafana-Org-Id` header along with your JWT when making API requests to Grafana. To learn more about the header, please refer to the [documentation](../../../../developers/http_api/#x-grafana-org-id-header). -### JMESPath examples +### Configure role mapping -To ease configuration of a proper JMESPath expression, you can test/evaluate expressions with custom payloads at http://jmespath.org/. +Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the JWT. -### Role mapping +The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.html) expression from the `role_attribute_path` configuration option. +To map the server administrator role, use the `allow_assign_grafana_admin` configuration option. -If the `role_attribute_path` property does not return a role, then the user is assigned the `Viewer` role by default. You can disable the role assignment by setting `role_attribute_strict = true`. It denies user access if no role or an invalid role is returned. +If no valid role is found, the user is assigned the role specified by [the `auto_assign_org_role` option](../../../configure-grafana/#auto_assign_org_role). +You can disable this default role assignment by setting `role_attribute_strict = true`. This setting denies user access if no role or an invalid role is returned after evaluating the `role_attribute_path` and the `org_mapping` expressions. + +You can use the `org_attribute_path` and `org_mapping` configuration options to assign the user to organizations and specify their role. For more information, refer to [Org roles mapping example](#org-roles-mapping-example). If both org role mapping (`org_mapping`) and the regular role mapping (`role_attribute_path`) are specified, then the user will get the highest of the two mapped roles. + +To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads. **Basic example:** @@ -224,9 +230,9 @@ Payload: } ``` -Config: +Configuration: -```bash +```ini role_attribute_path = role ``` @@ -251,12 +257,40 @@ Payload: } ``` -Config: +Configuration: -```bash +```ini role_attribute_path = contains(info.roles[*], 'admin') && 'Admin' || contains(info.roles[*], 'editor') && 'Editor' || 'Viewer' ``` +**Org roles mapping example** + +In the following example, the , the user has been granted the role of a `Viewer` in the `org_foo` organization, and the role of an `Editor` in the `org_bar` and `org_baz` organizations. + +Payload: + +```json +{ + ... + "info": { + ... + "orgs": [ + "engineer", + "admin", + ], + ... + }, + ... +} +``` + +Configuration: + +```ini +org_attribute_path = info.orgs +org_mapping = engineer:org_foo:Viewer admin:org_bar:Editor *:org_baz:Editor +``` + ### Grafana Admin Role If the `role_attribute_path` property returns a `GrafanaAdmin` role, Grafana Admin is not assigned by default, instead the `Admin` role is assigned. To allow `Grafana Admin` role to be assigned set `allow_assign_grafana_admin = true`. diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go index bad45e88ef5..444ae241c61 100644 --- a/pkg/services/authn/authnimpl/registration.go +++ b/pkg/services/authn/authnimpl/registration.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/login/social/connectors" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/apikey" @@ -112,7 +113,8 @@ func ProvideRegistration( } if cfg.JWTAuth.Enabled { - authnSvc.RegisterClient(clients.ProvideJWT(jwtService, cfg)) + orgRoleMapper := connectors.ProvideOrgRoleMapper(cfg, orgService) + authnSvc.RegisterClient(clients.ProvideJWT(jwtService, orgRoleMapper, cfg)) } if cfg.ExtJWTAuth.Enabled { diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index edc1f43dbc9..82323e21bfe 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/login/social/connectors" "github.com/grafana/grafana/pkg/services/auth" authJWT "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/authn" @@ -29,18 +30,22 @@ var ( "jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim")) ) -func ProvideJWT(jwtService auth.JWTVerifierService, cfg *setting.Cfg) *JWT { +func ProvideJWT(jwtService auth.JWTVerifierService, orgRoleMapper *connectors.OrgRoleMapper, cfg *setting.Cfg) *JWT { return &JWT{ - cfg: cfg, - log: log.New(authn.ClientJWT), - jwtService: jwtService, + cfg: cfg, + log: log.New(authn.ClientJWT), + jwtService: jwtService, + orgRoleMapper: orgRoleMapper, + orgMappingCfg: orgRoleMapper.ParseOrgMappingSettings(context.Background(), cfg.JWTAuth.OrgMapping, cfg.JWTAuth.RoleAttributeStrict), } } type JWT struct { - cfg *setting.Cfg - log log.Logger - jwtService auth.JWTVerifierService + cfg *setting.Cfg + orgRoleMapper *connectors.OrgRoleMapper + orgMappingCfg connectors.MappingConfiguration + log log.Logger + jwtService auth.JWTVerifierService } func (s *JWT) Name() string { @@ -102,32 +107,31 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi id.Name = name } - orgRoles, isGrafanaAdmin, err := getRoles(s.cfg, func() (org.RoleType, *bool, error) { - if s.cfg.JWTAuth.SkipOrgRoleSync { - return "", nil, nil - } - - role, grafanaAdmin := s.extractRoleAndAdmin(claims) - if s.cfg.JWTAuth.RoleAttributeStrict && !role.IsValid() { - return "", nil, errJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role) - } - - if !s.cfg.JWTAuth.AllowAssignGrafanaAdmin { - return role, nil, nil - } - - return role, &grafanaAdmin, nil - }) + id.Groups, err = s.extractGroups(claims) if err != nil { return nil, err } - id.OrgRoles = orgRoles - id.IsGrafanaAdmin = isGrafanaAdmin + if !s.cfg.JWTAuth.SkipOrgRoleSync { + role, grafanaAdmin := s.extractRoleAndAdmin(claims) + if err != nil { + s.log.Warn("Failed to extract role", "err", err) + } - id.Groups, err = s.extractGroups(claims) - if err != nil { - return nil, err + if s.cfg.JWTAuth.AllowAssignGrafanaAdmin { + id.IsGrafanaAdmin = &grafanaAdmin + } + + externalOrgs, err := s.extractOrgs(claims) + if err != nil { + s.log.Warn("Failed to extract orgs", "err", err) + return nil, err + } + + id.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, role) + if s.cfg.JWTAuth.RoleAttributeStrict && len(id.OrgRoles) == 0 { + return nil, errJWTInvalidRole.Errorf("could not evaluate any valid roles using IdP provided data") + } } if id.Login == "" && id.Email == "" { @@ -213,3 +217,12 @@ func (s *JWT) extractGroups(claims map[string]any) ([]string, error) { return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.GroupsAttributePath, claims) } + +// This code was copied from the social_base.go file and was adapted to match with the JWT structure +func (s *JWT) extractOrgs(claims map[string]any) ([]string, error) { + if s.cfg.JWTAuth.OrgAttributePath == "" { + return []string{}, nil + } + + return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.OrgAttributePath, claims) +} diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index c4381f3945e..7fccffc6b17 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -11,9 +11,12 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/login/social/connectors" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -136,6 +139,116 @@ func TestAuthenticateJWT(t *testing.T) { }, }, }, + { + name: "Valid Use case with org_mapping", + wantID: &authn.Identity{ + OrgID: 0, + OrgName: "", + OrgRoles: map[int64]identity.RoleType{4: identity.RoleEditor, 5: identity.RoleViewer}, + Login: "eai-doe", + Groups: []string{"foo", "bar"}, + Name: "Eai Doe", + Email: "eai.doe@cor.po", + IsGrafanaAdmin: boolPtr(false), + AuthenticatedBy: login.JWTModule, + AuthID: "1234567890", + IsDisabled: false, + HelpFlags1: 0, + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + FetchSyncedUser: true, + SyncOrgRoles: true, + SyncPermissions: true, + SyncTeams: true, + LookUpParams: login.UserLookupParams{ + Email: stringPtr("eai.doe@cor.po"), + Login: stringPtr("eai-doe"), + }, + }, + }, + verifyProvider: func(context.Context, string) (map[string]any, error) { + return map[string]any{ + "sub": "1234567890", + "email": "eai.doe@cor.po", + "preferred_username": "eai-doe", + "name": "Eai Doe", + "roles": "None", + "groups": []string{"foo", "bar"}, + "orgs": []string{"org1", "org2"}, + }, nil + }, + cfg: &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + EmailClaim: "email", + UsernameClaim: "preferred_username", + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + GroupsAttributePath: "groups[]", + OrgAttributePath: "orgs[]", + OrgMapping: []string{"org1:Org4:Editor", "org2:Org5:Viewer"}, + }, + }, + }, + { + name: "Invalid Use case with org_mapping and invalid roles", + wantID: &authn.Identity{ + OrgID: 0, + OrgName: "", + OrgRoles: map[int64]identity.RoleType{4: identity.RoleEditor, 5: identity.RoleViewer}, + Login: "eai-doe", + Groups: []string{"foo", "bar"}, + Name: "Eai Doe", + Email: "eai.doe@cor.po", + IsGrafanaAdmin: boolPtr(false), + AuthenticatedBy: login.JWTModule, + AuthID: "1234567890", + IsDisabled: false, + HelpFlags1: 0, + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + FetchSyncedUser: true, + SyncOrgRoles: true, + SyncPermissions: true, + SyncTeams: true, + LookUpParams: login.UserLookupParams{ + Email: stringPtr("eai.doe@cor.po"), + Login: stringPtr("eai-doe"), + }, + }, + }, + verifyProvider: func(context.Context, string) (map[string]any, error) { + return map[string]any{ + "sub": "1234567890", + "email": "eai.doe@cor.po", + "preferred_username": "eai-doe", + "name": "Eai Doe", + "roles": []string{"Invalid"}, + "groups": []string{"foo", "bar"}, + "orgs": []string{"org1", "org2"}, + }, nil + }, + cfg: &setting.Cfg{ + JWTAuth: setting.AuthJWTSettings{ + Enabled: true, + HeaderName: jwtHeaderName, + EmailClaim: "email", + UsernameClaim: "preferred_username", + AutoSignUp: true, + AllowAssignGrafanaAdmin: true, + RoleAttributeStrict: true, + RoleAttributePath: "roles", + GroupsAttributePath: "groups[]", + OrgAttributePath: "orgs[]", + OrgMapping: []string{"org1:Org4:Editor", "org2:Org5:Viewer"}, + }, + }, + }, } for _, tc := range testCases { @@ -146,7 +259,10 @@ func TestAuthenticateJWT(t *testing.T) { VerifyProvider: tc.verifyProvider, } - jwtClient := ProvideJWT(jwtService, tc.cfg) + jwtClient := ProvideJWT(jwtService, + connectors.ProvideOrgRoleMapper(tc.cfg, + &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}), + tc.cfg) validHTTPReq := &http.Request{ Header: map[string][]string{ jwtHeaderName: {"sample-token"}}, @@ -262,7 +378,9 @@ func TestJWTClaimConfig(t *testing.T) { Header: map[string][]string{ jwtHeaderName: {token}}, } - jwtClient := ProvideJWT(jwtService, cfg) + jwtClient := ProvideJWT(jwtService, connectors.ProvideOrgRoleMapper(cfg, + &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}), + cfg) _, err := jwtClient.Authenticate(context.Background(), &authn.Request{ OrgID: 1, HTTPRequest: httpReq, @@ -372,7 +490,10 @@ func TestJWTTest(t *testing.T) { RoleAttributeStrict: true, }, } - jwtClient := ProvideJWT(jwtService, cfg) + jwtClient := ProvideJWT(jwtService, + connectors.ProvideOrgRoleMapper(cfg, + &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}), + cfg) httpReq := &http.Request{ URL: &url.URL{RawQuery: "auth_token=" + tc.token}, Header: map[string][]string{ @@ -425,7 +546,10 @@ func TestJWTStripParam(t *testing.T) { httpReq := &http.Request{ URL: &url.URL{RawQuery: "auth_token=" + token + "&other_param=other_value"}, } - jwtClient := ProvideJWT(jwtService, cfg) + jwtClient := ProvideJWT(jwtService, + connectors.ProvideOrgRoleMapper(cfg, + &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}), + cfg) _, err := jwtClient.Authenticate(context.Background(), &authn.Request{ OrgID: 1, HTTPRequest: httpReq, @@ -481,7 +605,10 @@ func TestJWTSubClaimsConfig(t *testing.T) { }, } - jwtClient := ProvideJWT(jwtService, cfg) + jwtClient := ProvideJWT(jwtService, + connectors.ProvideOrgRoleMapper(cfg, + &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}), + cfg) identity, err := jwtClient.Authenticate(context.Background(), &authn.Request{ OrgID: 1, HTTPRequest: httpReq, diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go index 2a559a145b7..18c7866cccf 100644 --- a/pkg/setting/setting_jwt.go +++ b/pkg/setting/setting_jwt.go @@ -1,6 +1,10 @@ package setting -import "time" +import ( + "time" + + "github.com/grafana/grafana/pkg/util" +) const ( extJWTAccessTokenExpectAudience = "grafana" @@ -22,6 +26,8 @@ type AuthJWTSettings struct { AutoSignUp bool RoleAttributePath string RoleAttributeStrict bool + OrgMapping []string + OrgAttributePath string AllowAssignGrafanaAdmin bool SkipOrgRoleSync bool GroupsAttributePath string @@ -71,6 +77,8 @@ func (cfg *Cfg) readAuthJWTSettings() { jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "") jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_attribute_path", "") jwtSettings.TlsSkipVerify = authJWT.Key("tls_skip_verify_insecure").MustBool(false) + jwtSettings.OrgAttributePath = valueAsString(authJWT, "org_attribute_path", "") + jwtSettings.OrgMapping = util.SplitString(valueAsString(authJWT, "org_mapping", "")) cfg.JWTAuth = jwtSettings } From ba3e8014b3363762369c434c0c67d871a311a033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Fri, 21 Mar 2025 14:24:54 +0100 Subject: [PATCH 42/79] feat(unified-storage): add grpc connection pooling (#102575) --- go.mod | 1 + go.sum | 2 + .../src/types/featureToggles.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 8 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 14 ++++ pkg/storage/unified/client.go | 71 +++++++++++----- pkg/storage/unified/grpc_pool.go | 82 +++++++++++++++++++ pkg/storage/unified/resource/client.go | 5 +- 10 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 pkg/storage/unified/grpc_pool.go diff --git a/go.mod b/go.mod index c58a948ebc8..15242ea90db 100644 --- a/go.mod +++ b/go.mod @@ -559,6 +559,7 @@ require ( ) require ( + github.com/1NCE-GmbH/grpc-go-pool v0.0.0-20231117122434-2a5bb974daa2 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect diff --git a/go.sum b/go.sum index 4272556ca58..b8a9c60f8fb 100644 --- a/go.sum +++ b/go.sum @@ -643,6 +643,8 @@ filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= +github.com/1NCE-GmbH/grpc-go-pool v0.0.0-20231117122434-2a5bb974daa2 h1:qFYgLH2zZe3WHpQgUrzeazC+ebDebwAQqS9yE1cP5Bs= +github.com/1NCE-GmbH/grpc-go-pool v0.0.0-20231117122434-2a5bb974daa2/go.mod h1:09/ALd1AXCTCOfcJYD8+jIYKmFmi6PVCkTsipC18F7E= github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U= github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k= github.com/Azure/azure-sdk-for-go v23.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 99479a1b5ef..e8b2524d556 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1060,4 +1060,8 @@ export interface FeatureToggles { * Enables the unified storage history pruner */ unifiedStorageHistoryPruner?: boolean; + /** + * Enables the unified storage grpc connection pool + */ + unifiedStorageGrpcConnectionPool?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b1d30531435..0b2003074b9 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1830,6 +1830,14 @@ var ( HideFromAdminPage: true, HideFromDocs: true, }, + { + Name: "unifiedStorageGrpcConnectionPool", + Description: "Enables the unified storage grpc connection pool", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromAdminPage: true, + HideFromDocs: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 32ec8d224b1..e1b5bfda43a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -241,3 +241,4 @@ extraLanguages,experimental,@grafana/grafana-frontend-platform,false,false,true noBackdropBlur,experimental,@grafana/grafana-frontend-platform,false,false,true alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,false,false,false +unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index ed66a7b6e94..7207be7a8a2 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -974,4 +974,8 @@ const ( // FlagUnifiedStorageHistoryPruner // Enables the unified storage history pruner FlagUnifiedStorageHistoryPruner = "unifiedStorageHistoryPruner" + + // FlagUnifiedStorageGrpcConnectionPool + // Enables the unified storage grpc connection pool + FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index fbd2602c7a3..6de694f18da 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4203,6 +4203,20 @@ "codeowner": "@grafana/search-and-storage" } }, + { + "metadata": { + "name": "unifiedStorageGrpcConnectionPool", + "resourceVersion": "1742549790491", + "creationTimestamp": "2025-03-21T09:36:30Z" + }, + "spec": { + "description": "Enables the unified storage grpc connection pool", + "stage": "experimental", + "codeowner": "@grafana/search-and-storage", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "unifiedStorageHistoryPruner", diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index c16fbc92ad3..9239b260deb 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -117,10 +117,31 @@ func newClient(opts options.StorageOptions, return nil, fmt.Errorf("expecting address for storage_type: %s", opts.StorageType) } - // Create a connection to the gRPC server. - conn, err := GrpcConn(opts.Address, reg) - if err != nil { - return nil, err + var ( + conn grpc.ClientConnInterface + err error + metrics = newClientMetrics(reg) + ) + // Create either a connection pool or a single connection. + // The connection pool __can__ be useful when connection to + // server side load balancers like kube-proxy. + if features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageGrpcConnectionPool) { + conn, err = newPooledConn(&poolOpts{ + initialCapacity: 3, + maxCapacity: 6, + idleTimeout: time.Minute, + factory: func() (*grpc.ClientConn, error) { + return grpcConn(opts.Address, metrics) + }, + }) + if err != nil { + return nil, err + } + } else { + conn, err = grpcConn(opts.Address, metrics) + if err != nil { + return nil, err + } } // Create a client instance @@ -144,7 +165,7 @@ func newClient(opts options.StorageOptions, } } -func newResourceClient(conn *grpc.ClientConn, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (resource.ResourceClient, error) { +func newResourceClient(conn grpc.ClientConnInterface, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (resource.ResourceClient, error) { if !features.IsEnabledGlobally(featuremgmt.FlagAppPlatformGrpcClientAuth) { return resource.NewLegacyResourceClient(conn), nil } @@ -160,22 +181,8 @@ func newResourceClient(conn *grpc.ClientConn, cfg *setting.Cfg, features feature }) } -// GrpcConn creates a new gRPC connection to the provided address. -func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, error) { - // This works for now as the Provide function is only called once during startup. - // We might eventually want to tight this factory to a struct for more runtime control. - metrics := clientMetrics{ - requestDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ - Name: "resource_server_client_request_duration_seconds", - Help: "Time spent executing requests to the resource server.", - Buckets: prometheus.ExponentialBuckets(0.008, 4, 7), - }, []string{"operation", "status_code"}), - requestRetries: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ - Name: "resource_server_client_request_retries_total", - Help: "Total number of retries for requests to the resource server.", - }, []string{"operation"}), - } - +// grpcConn creates a new gRPC connection to the provided address. +func grpcConn(address string, metrics *clientMetrics) (*grpc.ClientConn, error) { // Report gRPC status code errors as labels. unary, stream := instrument(metrics.requestDuration, middleware.ReportGRPCStatusOption) @@ -212,6 +219,12 @@ func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, erro return grpc.NewClient(address, opts...) } +// GrpcConn is the public constructor that can be used for testing. +func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, error) { + metrics := newClientMetrics(reg) + return grpcConn(address, metrics) +} + // instrument is the same as grpcclient.Instrument but without the middleware.ClientUserHeaderInterceptor // and middleware.StreamClientUserHeaderInterceptor as we don't need them. func instrument(requestDuration *prometheus.HistogramVec, instrumentationLabelOptions ...middleware.InstrumentationOption) ([]grpc.UnaryClientInterceptor, []grpc.StreamClientInterceptor) { @@ -223,3 +236,19 @@ func instrument(requestDuration *prometheus.HistogramVec, instrumentationLabelOp middleware.StreamClientInstrumentInterceptor(requestDuration, instrumentationLabelOptions...), } } + +func newClientMetrics(reg prometheus.Registerer) *clientMetrics { + // This works for now as the Provide function is only called once during startup. + // We might eventually want to tight this factory to a struct for more runtime control. + return &clientMetrics{ + requestDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ + Name: "resource_server_client_request_duration_seconds", + Help: "Time spent executing requests to the resource server.", + Buckets: prometheus.ExponentialBuckets(0.008, 4, 7), + }, []string{"operation", "status_code"}), + requestRetries: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Name: "resource_server_client_request_retries_total", + Help: "Total number of retries for requests to the resource server.", + }, []string{"operation"}), + } +} diff --git a/pkg/storage/unified/grpc_pool.go b/pkg/storage/unified/grpc_pool.go new file mode 100644 index 00000000000..cebc8abce86 --- /dev/null +++ b/pkg/storage/unified/grpc_pool.go @@ -0,0 +1,82 @@ +package unified + +import ( + "context" + "errors" + "fmt" + "time" + + grpcpool "github.com/1NCE-GmbH/grpc-go-pool" + "google.golang.org/grpc" +) + +// pooledClientConn implements grpc.ClientConnInterface using a connection from a pool. +type pooledClientConn struct { + pool *grpcpool.Pool + // For streaming we want to keep a single connection, as otherwise we saturate the pool. + // Streaming should only be used for watching. + streamConn grpc.ClientConnInterface +} + +// Invoke implements the grpc.ClientConnInterface.Invoke method. +func (pc *pooledClientConn) Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...grpc.CallOption) error { + conn, err := pc.pool.Get(ctx) + if err != nil { + return fmt.Errorf("failed to create grpc conn in pooled client: %w", err) + } + // Return connection to pool when done. + defer func() { + _ = conn.Close() + }() + return conn.ClientConn.Invoke(ctx, method, args, reply, opts...) +} + +// NewStream implements the grpc.ClientConnInterface.NewStream method. +func (pc *pooledClientConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + stream, err := pc.streamConn.NewStream(ctx, desc, method, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create grpc stream in pooled client: %w", err) + } + return stream, nil +} + +type poolOpts struct { + initialCapacity int + maxCapacity int + idleTimeout time.Duration + factory func() (*grpc.ClientConn, error) +} + +func (opts *poolOpts) validate() error { + if opts.initialCapacity <= 0 { + return errors.New("initial capacity is required") + } + if opts.maxCapacity < opts.initialCapacity { + return errors.New("max capacity is less than initial capacity") + } + if opts.idleTimeout <= 0 { + return errors.New("idle timeout is required") + } + if opts.factory == nil { + return errors.New("factory is required") + } + return nil +} + +func newPooledConn(opts *poolOpts) (grpc.ClientConnInterface, error) { + if err := opts.validate(); err != nil { + return nil, fmt.Errorf("failed to validate grpc connection pool options: %w", err) + } + pool, err := grpcpool.New(opts.factory, opts.initialCapacity, opts.maxCapacity, opts.idleTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create grpc connection pool: %w", err) + } + streamConn, err := opts.factory() + if err != nil { + return nil, fmt.Errorf("failed to create groc streaming connection: %w", err) + } + return &pooledClientConn{ + pool: pool, + streamConn: streamConn, + }, nil +} diff --git a/pkg/storage/unified/resource/client.go b/pkg/storage/unified/resource/client.go index 649a0b7c9c0..c0eb700835d 100644 --- a/pkg/storage/unified/resource/client.go +++ b/pkg/storage/unified/resource/client.go @@ -14,6 +14,7 @@ import ( authnlib "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/tracing" @@ -40,7 +41,7 @@ type resourceClient struct { DiagnosticsClient } -func NewLegacyResourceClient(channel *grpc.ClientConn) ResourceClient { +func NewLegacyResourceClient(channel grpc.ClientConnInterface) ResourceClient { cc := grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor) return &resourceClient{ ResourceStoreClient: NewResourceStoreClient(cc), @@ -99,7 +100,7 @@ type RemoteResourceClientConfig struct { AllowInsecure bool } -func NewRemoteResourceClient(tracer tracing.Tracer, conn *grpc.ClientConn, cfg RemoteResourceClientConfig) (ResourceClient, error) { +func NewRemoteResourceClient(tracer tracing.Tracer, conn grpc.ClientConnInterface, cfg RemoteResourceClientConfig) (ResourceClient, error) { exchangeOpts := []authnlib.ExchangeClientOpts{} if cfg.AllowInsecure { From 8a8b1a0743eeb1067757504abfd47de217aec9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 21 Mar 2025 14:28:44 +0100 Subject: [PATCH 43/79] DashboardScene: Take selected object into account when adding objects (#102423) * Dashboard: Add object to selected object * Try not to nest rows * Update * Update * rows in rows, tabs in tabs * Update schema to allow nested rows and nested tabs * fix lint issue * reset v2alpha1 types from main * reset dashboard_object_gen.go to main --------- Co-authored-by: oscarkilhed --- .../kinds/v2alpha1/dashboard_spec.cue | 4 +- .../dashboard/v2alpha1/dashboard_spec_gen.go | 159 +- .../dashboard/v2alpha0/dashboard.schema.cue | 4 +- .../schema/dashboard/v2alpha0/types.gen.ts | 1942 ++++++++--------- .../edit-pane/DashboardEditPane.tsx | 4 + .../dashboard-scene/scene/DashboardScene.tsx | 21 +- .../DefaultGridLayoutManager.tsx | 1 + .../scene/layout-rows/RowsLayoutManager.tsx | 9 +- .../scene/layout-tabs/TabsLayoutManager.tsx | 9 +- .../scene/layouts-shared/addNew.ts | 47 +- .../layoutSerializers/RowsLayoutSerializer.ts | 3 - .../layoutSerializers/TabsLayoutSerializer.ts | 3 - 12 files changed, 1091 insertions(+), 1115 deletions(-) diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 26ca2d35eec..2396c0cc1aa 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -554,7 +554,7 @@ RowsLayoutRowSpec: { collapsed: bool conditionalRendering?: ConditionalRenderingGroupKind repeat?: RowRepeatOptions - layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind + layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind | RowsLayoutKind } ResponsiveGridLayoutKind: { @@ -595,7 +595,7 @@ TabsLayoutTabKind: { TabsLayoutTabSpec: { title?: string - layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind } PanelSpec: { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index b491bd8510d..801cd807bd3 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -836,17 +836,17 @@ func NewDashboardRowsLayoutRowKind() *DashboardRowsLayoutRowKind { // +k8s:openapi-gen=true type DashboardRowsLayoutRowSpec struct { - Title *string `json:"title,omitempty"` - Collapsed bool `json:"collapsed"` - ConditionalRendering *DashboardConditionalRenderingGroupKind `json:"conditionalRendering,omitempty"` - Repeat *DashboardRowRepeatOptions `json:"repeat,omitempty"` - Layout DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind `json:"layout"` + Title *string `json:"title,omitempty"` + Collapsed bool `json:"collapsed"` + ConditionalRendering *DashboardConditionalRenderingGroupKind `json:"conditionalRendering,omitempty"` + Repeat *DashboardRowRepeatOptions `json:"repeat,omitempty"` + Layout DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind `json:"layout"` } // NewDashboardRowsLayoutRowSpec creates a new DashboardRowsLayoutRowSpec object. func NewDashboardRowsLayoutRowSpec() *DashboardRowsLayoutRowSpec { return &DashboardRowsLayoutRowSpec{ - Layout: *NewDashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind(), + Layout: *NewDashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind(), } } @@ -1054,14 +1054,14 @@ func NewDashboardTabsLayoutTabKind() *DashboardTabsLayoutTabKind { // +k8s:openapi-gen=true type DashboardTabsLayoutTabSpec struct { - Title *string `json:"title,omitempty"` - Layout DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind `json:"layout"` + Title *string `json:"title,omitempty"` + Layout DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind `json:"layout"` } // NewDashboardTabsLayoutTabSpec creates a new DashboardTabsLayoutTabSpec object. func NewDashboardTabsLayoutTabSpec() *DashboardTabsLayoutTabSpec { return &DashboardTabsLayoutTabSpec{ - Layout: *NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind(), + Layout: *NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind(), } } @@ -2012,19 +2012,20 @@ func (resource *DashboardGridLayoutItemKindOrGridLayoutRowKind) UnmarshalJSON(ra } // +k8s:openapi-gen=true -type DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind struct { +type DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind struct { GridLayoutKind *DashboardGridLayoutKind `json:"GridLayoutKind,omitempty"` ResponsiveGridLayoutKind *DashboardResponsiveGridLayoutKind `json:"ResponsiveGridLayoutKind,omitempty"` TabsLayoutKind *DashboardTabsLayoutKind `json:"TabsLayoutKind,omitempty"` + RowsLayoutKind *DashboardRowsLayoutKind `json:"RowsLayoutKind,omitempty"` } -// NewDashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind creates a new DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind object. -func NewDashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind() *DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind { - return &DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind{} +// NewDashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind creates a new DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind object. +func NewDashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind() *DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind { + return &DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind{} } -// MarshalJSON implements a custom JSON marshalling logic to encode `DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind` as JSON. -func (resource DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind) MarshalJSON() ([]byte, error) { +// MarshalJSON implements a custom JSON marshalling logic to encode `DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind` as JSON. +func (resource DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind) MarshalJSON() ([]byte, error) { if resource.GridLayoutKind != nil { return json.Marshal(resource.GridLayoutKind) } @@ -2034,11 +2035,14 @@ func (resource DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind if resource.TabsLayoutKind != nil { return json.Marshal(resource.TabsLayoutKind) } + if resource.RowsLayoutKind != nil { + return json.Marshal(resource.RowsLayoutKind) + } return []byte("null"), nil } -// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind` from JSON. -func (resource *DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind) UnmarshalJSON(raw []byte) error { +// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind` from JSON. +func (resource *DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind) UnmarshalJSON(raw []byte) error { if raw == nil { return nil } @@ -2071,6 +2075,14 @@ func (resource *DashboardGridLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKin resource.ResponsiveGridLayoutKind = &dashboardResponsiveGridLayoutKind return nil + case "RowsLayout": + var dashboardRowsLayoutKind DashboardRowsLayoutKind + if err := json.Unmarshal(raw, &dashboardRowsLayoutKind); err != nil { + return err + } + + resource.RowsLayoutKind = &dashboardRowsLayoutKind + return nil case "TabsLayout": var dashboardTabsLayoutKind DashboardTabsLayoutKind if err := json.Unmarshal(raw, &dashboardTabsLayoutKind); err != nil { @@ -2158,19 +2170,20 @@ func (resource *DashboardConditionalRenderingVariableKindOrConditionalRenderingD } // +k8s:openapi-gen=true -type DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind struct { +type DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind struct { GridLayoutKind *DashboardGridLayoutKind `json:"GridLayoutKind,omitempty"` RowsLayoutKind *DashboardRowsLayoutKind `json:"RowsLayoutKind,omitempty"` ResponsiveGridLayoutKind *DashboardResponsiveGridLayoutKind `json:"ResponsiveGridLayoutKind,omitempty"` + TabsLayoutKind *DashboardTabsLayoutKind `json:"TabsLayoutKind,omitempty"` } -// NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind creates a new DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind object. -func NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind() *DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind { - return &DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind{} +// NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind creates a new DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind object. +func NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind() *DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind { + return &DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind{} } -// MarshalJSON implements a custom JSON marshalling logic to encode `DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind` as JSON. -func (resource DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind) MarshalJSON() ([]byte, error) { +// MarshalJSON implements a custom JSON marshalling logic to encode `DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind` as JSON. +func (resource DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind) MarshalJSON() ([]byte, error) { if resource.GridLayoutKind != nil { return json.Marshal(resource.GridLayoutKind) } @@ -2180,11 +2193,14 @@ func (resource DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind if resource.ResponsiveGridLayoutKind != nil { return json.Marshal(resource.ResponsiveGridLayoutKind) } + if resource.TabsLayoutKind != nil { + return json.Marshal(resource.TabsLayoutKind) + } return []byte("null"), nil } -// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind` from JSON. -func (resource *DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKind) UnmarshalJSON(raw []byte) error { +// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind` from JSON. +func (resource *DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind) UnmarshalJSON(raw []byte) error { if raw == nil { return nil } @@ -2225,6 +2241,14 @@ func (resource *DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKin resource.RowsLayoutKind = &dashboardRowsLayoutKind return nil + case "TabsLayout": + var dashboardTabsLayoutKind DashboardTabsLayoutKind + if err := json.Unmarshal(raw, &dashboardTabsLayoutKind); err != nil { + return err + } + + resource.TabsLayoutKind = &dashboardTabsLayoutKind + return nil } return fmt.Errorf("could not unmarshal resource with `kind = %v`", discriminator) @@ -2472,88 +2496,3 @@ func (resource *DashboardStringOrFloat64) UnmarshalJSON(raw []byte) error { return errors.Join(errList...) } - -// +k8s:openapi-gen=true -type DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind struct { - GridLayoutKind *DashboardGridLayoutKind `json:"GridLayoutKind,omitempty"` - RowsLayoutKind *DashboardRowsLayoutKind `json:"RowsLayoutKind,omitempty"` - ResponsiveGridLayoutKind *DashboardResponsiveGridLayoutKind `json:"ResponsiveGridLayoutKind,omitempty"` - TabsLayoutKind *DashboardTabsLayoutKind `json:"TabsLayoutKind,omitempty"` -} - -// NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind creates a new DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind object. -func NewDashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind() *DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind { - return &DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind{} -} - -// MarshalJSON implements a custom JSON marshalling logic to encode `DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind` as JSON. -func (resource DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind) MarshalJSON() ([]byte, error) { - if resource.GridLayoutKind != nil { - return json.Marshal(resource.GridLayoutKind) - } - if resource.RowsLayoutKind != nil { - return json.Marshal(resource.RowsLayoutKind) - } - if resource.ResponsiveGridLayoutKind != nil { - return json.Marshal(resource.ResponsiveGridLayoutKind) - } - if resource.TabsLayoutKind != nil { - return json.Marshal(resource.TabsLayoutKind) - } - return []byte("null"), nil -} - -// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind` from JSON. -func (resource *DashboardGridLayoutKindOrRowsLayoutKindOrResponsiveGridLayoutKindOrTabsLayoutKind) UnmarshalJSON(raw []byte) error { - if raw == nil { - return nil - } - - // FIXME: this is wasteful, we need to find a more efficient way to unmarshal this. - parsedAsMap := make(map[string]interface{}) - if err := json.Unmarshal(raw, &parsedAsMap); err != nil { - return err - } - - discriminator, found := parsedAsMap["kind"] - if !found { - return errors.New("discriminator field 'kind' not found in payload") - } - - switch discriminator { - case "GridLayout": - var dashboardGridLayoutKind DashboardGridLayoutKind - if err := json.Unmarshal(raw, &dashboardGridLayoutKind); err != nil { - return err - } - - resource.GridLayoutKind = &dashboardGridLayoutKind - return nil - case "ResponsiveGridLayout": - var dashboardResponsiveGridLayoutKind DashboardResponsiveGridLayoutKind - if err := json.Unmarshal(raw, &dashboardResponsiveGridLayoutKind); err != nil { - return err - } - - resource.ResponsiveGridLayoutKind = &dashboardResponsiveGridLayoutKind - return nil - case "RowsLayout": - var dashboardRowsLayoutKind DashboardRowsLayoutKind - if err := json.Unmarshal(raw, &dashboardRowsLayoutKind); err != nil { - return err - } - - resource.RowsLayoutKind = &dashboardRowsLayoutKind - return nil - case "TabsLayout": - var dashboardTabsLayoutKind DashboardTabsLayoutKind - if err := json.Unmarshal(raw, &dashboardTabsLayoutKind); err != nil { - return err - } - - resource.TabsLayoutKind = &dashboardTabsLayoutKind - return nil - } - - return fmt.Errorf("could not unmarshal resource with `kind = %v`", discriminator) -} diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index 846d0cabd8f..e0fdc845ec3 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -554,7 +554,7 @@ RowsLayoutRowSpec: { collapsed: bool repeat?: RowRepeatOptions conditionalRendering?: ConditionalRenderingGroupKind - layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind + layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind | RowsLayoutKind } ResponsiveGridLayoutKind: { @@ -595,7 +595,7 @@ TabsLayoutTabKind: { TabsLayoutTabSpec: { title?: string - layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind } PanelSpec: { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index 895014f6b6c..022ee9f3bc8 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -51,49 +51,54 @@ export const defaultDashboardV2Spec = (): DashboardV2Spec => ({ variables: [], }); -// Supported dashboard elements -// |* more element types in the future -export type Element = PanelKind | LibraryPanelKind; - -export const defaultElement = (): Element => (defaultPanelKind()); - -export interface LibraryPanelKind { - kind: "LibraryPanel"; - spec: LibraryPanelSpec; +export interface AnnotationQueryKind { + kind: "AnnotationQuery"; + spec: AnnotationQuerySpec; } -export const defaultLibraryPanelKind = (): LibraryPanelKind => ({ - kind: "LibraryPanel", - spec: defaultLibraryPanelSpec(), +export const defaultAnnotationQueryKind = (): AnnotationQueryKind => ({ + kind: "AnnotationQuery", + spec: defaultAnnotationQuerySpec(), }); -export interface LibraryPanelSpec { - // Panel ID for the library panel in the dashboard - id: number; - // Title for the library panel in the dashboard - title: string; - libraryPanel: LibraryPanelRef; -} - -export const defaultLibraryPanelSpec = (): LibraryPanelSpec => ({ - id: 0, - title: "", - libraryPanel: defaultLibraryPanelRef(), -}); - -// A library panel is a reusable panel that you can use in any dashboard. -// When you make a change to a library panel, that change propagates to all instances of where the panel is used. -// Library panels streamline reuse of panels across multiple dashboards. -export interface LibraryPanelRef { - // Library panel name +export interface AnnotationQuerySpec { + datasource?: DataSourceRef; + query?: DataQueryKind; + enable: boolean; + hide: boolean; + iconColor: string; name: string; - // Library panel uid - uid: string; + builtIn?: boolean; + filter?: AnnotationPanelFilter; } -export const defaultLibraryPanelRef = (): LibraryPanelRef => ({ +export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({ + enable: false, + hide: false, + iconColor: "", name: "", - uid: "", + builtIn: false, +}); + +export interface DataSourceRef { + // The plugin type-id + type?: string; + // Specific datasource instance + uid?: string; +} + +export const defaultDataSourceRef = (): DataSourceRef => ({ +}); + +export interface DataQueryKind { + // The kind of a DataQueryKind is the datasource type + kind: string; + spec: Record; +} + +export const defaultDataQueryKind = (): DataQueryKind => ({ + kind: "", + spec: {}, }); export interface AnnotationPanelFilter { @@ -115,51 +120,106 @@ export type DashboardCursorSync = "Off" | "Crosshair" | "Tooltip"; export const defaultDashboardCursorSync = (): DashboardCursorSync => ("Off"); -// Links with references to other dashboards or external resources -export interface DashboardLink { - // Title to display with the link - title: string; - // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) - // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType` - type: DashboardLinkType; - // Icon name to be displayed with the link - icon: string; - // Tooltip to display when the user hovers their mouse over it - tooltip: string; - // Link URL. Only required/valid if the type is link - url?: string; - // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards - tags: string[]; - // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards - asDropdown: boolean; - // If true, the link will be opened in a new tab - targetBlank: boolean; - // If true, includes current template variables values in the link as query params - includeVars: boolean; - // If true, includes current time range in the link as query params - keepTime: boolean; +// Supported dashboard elements +// |* more element types in the future +export type Element = PanelKind | LibraryPanelKind; + +export const defaultElement = (): Element => (defaultPanelKind()); + +export interface PanelKind { + kind: "Panel"; + spec: PanelSpec; } -export const defaultDashboardLink = (): DashboardLink => ({ - title: "", - type: "link", - icon: "", - tooltip: "", - tags: [], - asDropdown: false, - targetBlank: false, - includeVars: false, - keepTime: false, +export const defaultPanelKind = (): PanelKind => ({ + kind: "Panel", + spec: defaultPanelSpec(), }); -export interface DataSourceRef { - // The plugin type-id - type?: string; - // Specific datasource instance - uid?: string; +export interface PanelSpec { + id: number; + title: string; + description: string; + links: DataLink[]; + data: QueryGroupKind; + vizConfig: VizConfigKind; + transparent?: boolean; } -export const defaultDataSourceRef = (): DataSourceRef => ({ +export const defaultPanelSpec = (): PanelSpec => ({ + id: 0, + title: "", + description: "", + links: [], + data: defaultQueryGroupKind(), + vizConfig: defaultVizConfigKind(), +}); + +export interface DataLink { + title: string; + url: string; + targetBlank?: boolean; +} + +export const defaultDataLink = (): DataLink => ({ + title: "", + url: "", +}); + +export interface QueryGroupKind { + kind: "QueryGroup"; + spec: QueryGroupSpec; +} + +export const defaultQueryGroupKind = (): QueryGroupKind => ({ + kind: "QueryGroup", + spec: defaultQueryGroupSpec(), +}); + +export interface QueryGroupSpec { + queries: PanelQueryKind[]; + transformations: TransformationKind[]; + queryOptions: QueryOptionsSpec; +} + +export const defaultQueryGroupSpec = (): QueryGroupSpec => ({ + queries: [], + transformations: [], + queryOptions: defaultQueryOptionsSpec(), +}); + +export interface PanelQueryKind { + kind: "PanelQuery"; + spec: PanelQuerySpec; +} + +export const defaultPanelQueryKind = (): PanelQueryKind => ({ + kind: "PanelQuery", + spec: defaultPanelQuerySpec(), +}); + +export interface PanelQuerySpec { + query: DataQueryKind; + datasource?: DataSourceRef; + refId: string; + hidden: boolean; +} + +export const defaultPanelQuerySpec = (): PanelQuerySpec => ({ + query: defaultDataQueryKind(), + refId: "", + hidden: false, +}); + +export interface TransformationKind { + // The kind of a TransformationKind is the transformation ID + kind: string; + spec: DataTransformerConfig; +} + +export const defaultTransformationKind = (): TransformationKind => ({ + kind: "", + spec: defaultDataTransformerConfig(), }); // Transformations allow to manipulate data returned by a query before the system applies a visualization. @@ -184,15 +244,54 @@ export const defaultDataTransformerConfig = (): DataTransformerConfig => ({ options: {}, }); -export interface DataLink { - title: string; - url: string; - targetBlank?: boolean; +// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. +// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. +export interface MatcherConfig { + // The matcher id. This is used to find the matcher implementation from registry. + id: string; + // The matcher options. This is specific to the matcher implementation. + options?: any; } -export const defaultDataLink = (): DataLink => ({ - title: "", - url: "", +export const defaultMatcherConfig = (): MatcherConfig => ({ + id: "", +}); + +export interface QueryOptionsSpec { + timeFrom?: string; + maxDataPoints?: number; + timeShift?: string; + queryCachingTTL?: number; + interval?: string; + cacheTimeout?: string; + hideTimeOverride?: boolean; +} + +export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({ +}); + +export interface VizConfigKind { + // The kind of a VizConfigKind is the plugin ID + kind: string; + spec: VizConfigSpec; +} + +export const defaultVizConfigKind = (): VizConfigKind => ({ + kind: "", + spec: defaultVizConfigSpec(), +}); + +// --- Kinds --- +export interface VizConfigSpec { + pluginVersion: string; + options: Record; + fieldConfig: FieldConfigSource; +} + +export const defaultVizConfigSpec = (): VizConfigSpec => ({ + pluginVersion: "", + options: {}, + fieldConfig: defaultFieldConfigSource(), }); // The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. @@ -272,65 +371,10 @@ export interface FieldConfig { export const defaultFieldConfig = (): FieldConfig => ({ }); -export interface DynamicConfigValue { - id: string; - value?: any; -} - -export const defaultDynamicConfigValue = (): DynamicConfigValue => ({ - id: "", -}); - -// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. -// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. -export interface MatcherConfig { - // The matcher id. This is used to find the matcher implementation from registry. - id: string; - // The matcher options. This is specific to the matcher implementation. - options?: any; -} - -export const defaultMatcherConfig = (): MatcherConfig => ({ - id: "", -}); - -export interface Threshold { - value: number; - color: string; -} - -export const defaultThreshold = (): Threshold => ({ - value: 0, - color: "", -}); - -export type ThresholdsMode = "absolute" | "percentage"; - -export const defaultThresholdsMode = (): ThresholdsMode => ("absolute"); - -export interface ThresholdsConfig { - mode: ThresholdsMode; - steps: Threshold[]; -} - -export const defaultThresholdsConfig = (): ThresholdsConfig => ({ - mode: "absolute", - steps: [], -}); - export type ValueMapping = ValueMap | RangeMap | RegexMap | SpecialValueMap; export const defaultValueMapping = (): ValueMapping => (defaultValueMap()); -// Supported value mapping types -// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. -// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. -// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. -// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. -export type MappingType = "value" | "range" | "regex" | "special"; - -export const defaultMappingType = (): MappingType => ("value"); - // Maps text values to a color or different display text and color. // For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. export interface ValueMap { @@ -344,6 +388,21 @@ export const defaultValueMap = (): ValueMap => ({ options: {}, }); +// Result used as replacement with text and color when the value matches +export interface ValueMappingResult { + // Text to display when the value matches + text?: string; + // Text to use when the value matches + color?: string; + // Icon to display when the value matches. Only specific visualizations. + icon?: string; + // Position in the mapping array. Only used internally. + index?: number; +} + +export const defaultValueMappingResult = (): ValueMappingResult => ({ +}); + // Maps numerical ranges to a display text and color. // For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. export interface RangeMap { @@ -415,19 +474,42 @@ export type SpecialValueMatch = "true" | "false" | "null" | "nan" | "null+nan" | export const defaultSpecialValueMatch = (): SpecialValueMatch => ("true"); -// Result used as replacement with text and color when the value matches -export interface ValueMappingResult { - // Text to display when the value matches - text?: string; - // Text to use when the value matches - color?: string; - // Icon to display when the value matches. Only specific visualizations. - icon?: string; - // Position in the mapping array. Only used internally. - index?: number; +export interface ThresholdsConfig { + mode: ThresholdsMode; + steps: Threshold[]; } -export const defaultValueMappingResult = (): ValueMappingResult => ({ +export const defaultThresholdsConfig = (): ThresholdsConfig => ({ + mode: "absolute", + steps: [], +}); + +export type ThresholdsMode = "absolute" | "percentage"; + +export const defaultThresholdsMode = (): ThresholdsMode => ("absolute"); + +export interface Threshold { + value: number; + color: string; +} + +export const defaultThreshold = (): Threshold => ({ + value: 0, + color: "", +}); + +// Map a field to a color. +export interface FieldColor { + // The main color scheme mode. + mode: FieldColorModeId; + // The fixed color value for fixed or shades color modes. + fixedColor?: string; + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: FieldColorSeriesByMode; +} + +export const defaultFieldColor = (): FieldColor => ({ + mode: "thresholds", }); // Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. @@ -457,268 +539,80 @@ export type FieldColorSeriesByMode = "min" | "max" | "last"; export const defaultFieldColorSeriesByMode = (): FieldColorSeriesByMode => ("min"); -// Map a field to a color. -export interface FieldColor { - // The main color scheme mode. - mode: FieldColorModeId; - // The fixed color value for fixed or shades color modes. - fixedColor?: string; - // Some visualizations need to know how to assign a series color from by value color schemes. - seriesBy?: FieldColorSeriesByMode; +export interface DynamicConfigValue { + id: string; + value?: any; } -export const defaultFieldColor = (): FieldColor => ({ - mode: "thresholds", +export const defaultDynamicConfigValue = (): DynamicConfigValue => ({ + id: "", }); -// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) -export type DashboardLinkType = "link" | "dashboards"; - -export const defaultDashboardLinkType = (): DashboardLinkType => ("link"); - -// --- Common types --- -export interface Kind { - kind: string; - spec: any; - metadata?: any; +export interface LibraryPanelKind { + kind: "LibraryPanel"; + spec: LibraryPanelSpec; } -export const defaultKind = (): Kind => ({ - kind: "", - spec: {}, +export const defaultLibraryPanelKind = (): LibraryPanelKind => ({ + kind: "LibraryPanel", + spec: defaultLibraryPanelSpec(), }); -// --- Kinds --- -export interface VizConfigSpec { - pluginVersion: string; - options: Record; - fieldConfig: FieldConfigSource; +export interface LibraryPanelSpec { + // Panel ID for the library panel in the dashboard + id: number; + // Title for the library panel in the dashboard + title: string; + libraryPanel: LibraryPanelRef; } -export const defaultVizConfigSpec = (): VizConfigSpec => ({ - pluginVersion: "", - options: {}, - fieldConfig: defaultFieldConfigSource(), +export const defaultLibraryPanelSpec = (): LibraryPanelSpec => ({ + id: 0, + title: "", + libraryPanel: defaultLibraryPanelRef(), }); -export interface VizConfigKind { - // The kind of a VizConfigKind is the plugin ID - kind: string; - spec: VizConfigSpec; -} - -export const defaultVizConfigKind = (): VizConfigKind => ({ - kind: "", - spec: defaultVizConfigSpec(), -}); - -export interface AnnotationQuerySpec { - datasource?: DataSourceRef; - query?: DataQueryKind; - enable: boolean; - hide: boolean; - iconColor: string; +// A library panel is a reusable panel that you can use in any dashboard. +// When you make a change to a library panel, that change propagates to all instances of where the panel is used. +// Library panels streamline reuse of panels across multiple dashboards. +export interface LibraryPanelRef { + // Library panel name name: string; - builtIn?: boolean; - filter?: AnnotationPanelFilter; + // Library panel uid + uid: string; } -export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({ - enable: false, - hide: false, - iconColor: "", +export const defaultLibraryPanelRef = (): LibraryPanelRef => ({ name: "", - builtIn: false, + uid: "", }); -export interface AnnotationQueryKind { - kind: "AnnotationQuery"; - spec: AnnotationQuerySpec; +export interface GridLayoutKind { + kind: "GridLayout"; + spec: GridLayoutSpec; } -export const defaultAnnotationQueryKind = (): AnnotationQueryKind => ({ - kind: "AnnotationQuery", - spec: defaultAnnotationQuerySpec(), +export const defaultGridLayoutKind = (): GridLayoutKind => ({ + kind: "GridLayout", + spec: defaultGridLayoutSpec(), }); -export interface QueryOptionsSpec { - timeFrom?: string; - maxDataPoints?: number; - timeShift?: string; - queryCachingTTL?: number; - interval?: string; - cacheTimeout?: string; - hideTimeOverride?: boolean; +export interface GridLayoutSpec { + items: (GridLayoutItemKind | GridLayoutRowKind)[]; } -export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({ +export const defaultGridLayoutSpec = (): GridLayoutSpec => ({ + items: [], }); -export interface DataQueryKind { - // The kind of a DataQueryKind is the datasource type - kind: string; - spec: Record; +export interface GridLayoutItemKind { + kind: "GridLayoutItem"; + spec: GridLayoutItemSpec; } -export const defaultDataQueryKind = (): DataQueryKind => ({ - kind: "", - spec: {}, -}); - -export interface PanelQuerySpec { - query: DataQueryKind; - datasource?: DataSourceRef; - refId: string; - hidden: boolean; -} - -export const defaultPanelQuerySpec = (): PanelQuerySpec => ({ - query: defaultDataQueryKind(), - refId: "", - hidden: false, -}); - -export interface PanelQueryKind { - kind: "PanelQuery"; - spec: PanelQuerySpec; -} - -export const defaultPanelQueryKind = (): PanelQueryKind => ({ - kind: "PanelQuery", - spec: defaultPanelQuerySpec(), -}); - -export interface TransformationKind { - // The kind of a TransformationKind is the transformation ID - kind: string; - spec: DataTransformerConfig; -} - -export const defaultTransformationKind = (): TransformationKind => ({ - kind: "", - spec: defaultDataTransformerConfig(), -}); - -export interface QueryGroupSpec { - queries: PanelQueryKind[]; - transformations: TransformationKind[]; - queryOptions: QueryOptionsSpec; -} - -export const defaultQueryGroupSpec = (): QueryGroupSpec => ({ - queries: [], - transformations: [], - queryOptions: defaultQueryOptionsSpec(), -}); - -export interface QueryGroupKind { - kind: "QueryGroup"; - spec: QueryGroupSpec; -} - -export const defaultQueryGroupKind = (): QueryGroupKind => ({ - kind: "QueryGroup", - spec: defaultQueryGroupSpec(), -}); - -export interface TimeRangeOption { - display: string; - from: string; - to: string; -} - -export const defaultTimeRangeOption = (): TimeRangeOption => ({ - display: "Last 6 hours", - from: "now-6h", - to: "now", -}); - -// Time configuration -// It defines the default time config for the time picker, the refresh picker for the specific dashboard. -export interface TimeSettingsSpec { - // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". - timezone?: string; - // Start time range for dashboard. - // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". - from: string; - // End time range for dashboard. - // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". - to: string; - // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". - // v1: refresh - autoRefresh: string; - // Interval options available in the refresh picker dropdown. - // v1: timepicker.refresh_intervals - autoRefreshIntervals: string[]; - // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - // v1: timepicker.quick_ranges , not exposed in the UI - quickRanges?: TimeRangeOption[]; - // Whether timepicker is visible or not. - // v1: timepicker.hidden - hideTimepicker: boolean; - // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". - weekStart?: "saturday" | "monday" | "sunday"; - // The month that the fiscal year starts on. 0 = January, 11 = December - fiscalYearStartMonth: number; - // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. - // v1: timepicker.nowDelay - nowDelay?: string; -} - -export const defaultTimeSettingsSpec = (): TimeSettingsSpec => ({ - timezone: "browser", - from: "now-6h", - to: "now", - autoRefresh: "", - autoRefreshIntervals: [ -"5s", -"10s", -"30s", -"1m", -"5m", -"15m", -"30m", -"1h", -"2h", -"1d", -], - hideTimepicker: false, - fiscalYearStartMonth: 0, -}); - -// other repeat modes will be added in the future: label, frame -export const RepeatMode = "variable"; - -export interface RepeatOptions { - mode: "variable"; - value: string; - direction?: "h" | "v"; - maxPerRow?: number; -} - -export const defaultRepeatOptions = (): RepeatOptions => ({ - mode: RepeatMode, - value: "", -}); - -export interface RowRepeatOptions { - mode: "variable"; - value: string; -} - -export const defaultRowRepeatOptions = (): RowRepeatOptions => ({ - mode: RepeatMode, - value: "", -}); - -export interface ResponsiveGridRepeatOptions { - mode: "variable"; - value: string; -} - -export const defaultResponsiveGridRepeatOptions = (): ResponsiveGridRepeatOptions => ({ - mode: RepeatMode, - value: "", +export const defaultGridLayoutItemKind = (): GridLayoutItemKind => ({ + kind: "GridLayoutItem", + spec: defaultGridLayoutItemSpec(), }); export interface GridLayoutItemSpec { @@ -739,16 +633,31 @@ export const defaultGridLayoutItemSpec = (): GridLayoutItemSpec => ({ element: defaultElementReference(), }); -export interface GridLayoutItemKind { - kind: "GridLayoutItem"; - spec: GridLayoutItemSpec; +export interface ElementReference { + kind: "ElementReference"; + name: string; } -export const defaultGridLayoutItemKind = (): GridLayoutItemKind => ({ - kind: "GridLayoutItem", - spec: defaultGridLayoutItemSpec(), +export const defaultElementReference = (): ElementReference => ({ + kind: "ElementReference", + name: "", }); +export interface RepeatOptions { + mode: "variable"; + value: string; + direction?: "h" | "v"; + maxPerRow?: number; +} + +export const defaultRepeatOptions = (): RepeatOptions => ({ + mode: RepeatMode, + value: "", +}); + +// other repeat modes will be added in the future: label, frame +export const RepeatMode = "variable"; + export interface GridLayoutRowKind { kind: "GridLayoutRow"; spec: GridLayoutRowSpec; @@ -775,22 +684,14 @@ export const defaultGridLayoutRowSpec = (): GridLayoutRowSpec => ({ elements: [], }); -export interface GridLayoutSpec { - items: (GridLayoutItemKind | GridLayoutRowKind)[]; +export interface RowRepeatOptions { + mode: "variable"; + value: string; } -export const defaultGridLayoutSpec = (): GridLayoutSpec => ({ - items: [], -}); - -export interface GridLayoutKind { - kind: "GridLayout"; - spec: GridLayoutSpec; -} - -export const defaultGridLayoutKind = (): GridLayoutKind => ({ - kind: "GridLayout", - spec: defaultGridLayoutSpec(), +export const defaultRowRepeatOptions = (): RowRepeatOptions => ({ + mode: RepeatMode, + value: "", }); export interface RowsLayoutKind { @@ -826,7 +727,7 @@ export interface RowsLayoutRowSpec { collapsed: boolean; repeat?: RowRepeatOptions; conditionalRendering?: ConditionalRenderingGroupKind; - layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind; + layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind | RowsLayoutKind; } export const defaultRowsLayoutRowSpec = (): RowsLayoutRowSpec => ({ @@ -834,575 +735,6 @@ export const defaultRowsLayoutRowSpec = (): RowsLayoutRowSpec => ({ layout: defaultGridLayoutKind(), }); -export interface ResponsiveGridLayoutKind { - kind: "ResponsiveGridLayout"; - spec: ResponsiveGridLayoutSpec; -} - -export const defaultResponsiveGridLayoutKind = (): ResponsiveGridLayoutKind => ({ - kind: "ResponsiveGridLayout", - spec: defaultResponsiveGridLayoutSpec(), -}); - -export interface ResponsiveGridLayoutSpec { - row: string; - col: string; - items: ResponsiveGridLayoutItemKind[]; -} - -export const defaultResponsiveGridLayoutSpec = (): ResponsiveGridLayoutSpec => ({ - row: "", - col: "", - items: [], -}); - -export interface ResponsiveGridLayoutItemKind { - kind: "ResponsiveGridLayoutItem"; - spec: ResponsiveGridLayoutItemSpec; -} - -export const defaultResponsiveGridLayoutItemKind = (): ResponsiveGridLayoutItemKind => ({ - kind: "ResponsiveGridLayoutItem", - spec: defaultResponsiveGridLayoutItemSpec(), -}); - -export interface ResponsiveGridLayoutItemSpec { - element: ElementReference; - repeat?: ResponsiveGridRepeatOptions; - conditionalRendering?: ConditionalRenderingGroupKind; -} - -export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({ - element: defaultElementReference(), -}); - -export interface TabsLayoutKind { - kind: "TabsLayout"; - spec: TabsLayoutSpec; -} - -export const defaultTabsLayoutKind = (): TabsLayoutKind => ({ - kind: "TabsLayout", - spec: defaultTabsLayoutSpec(), -}); - -export interface TabsLayoutSpec { - tabs: TabsLayoutTabKind[]; -} - -export const defaultTabsLayoutSpec = (): TabsLayoutSpec => ({ - tabs: [], -}); - -export interface TabsLayoutTabKind { - kind: "TabsLayoutTab"; - spec: TabsLayoutTabSpec; -} - -export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({ - kind: "TabsLayoutTab", - spec: defaultTabsLayoutTabSpec(), -}); - -export interface TabsLayoutTabSpec { - title?: string; - layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind; -} - -export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({ - layout: defaultGridLayoutKind(), -}); - -export interface PanelSpec { - id: number; - title: string; - description: string; - links: DataLink[]; - data: QueryGroupKind; - vizConfig: VizConfigKind; - transparent?: boolean; -} - -export const defaultPanelSpec = (): PanelSpec => ({ - id: 0, - title: "", - description: "", - links: [], - data: defaultQueryGroupKind(), - vizConfig: defaultVizConfigKind(), -}); - -export interface PanelKind { - kind: "Panel"; - spec: PanelSpec; -} - -export const defaultPanelKind = (): PanelKind => ({ - kind: "Panel", - spec: defaultPanelSpec(), -}); - -export interface ElementReference { - kind: "ElementReference"; - name: string; -} - -export const defaultElementReference = (): ElementReference => ({ - kind: "ElementReference", - name: "", -}); - -// Variable types -export type VariableValue = VariableValueSingle | VariableValueSingle[]; - -export const defaultVariableValue = (): VariableValue => (defaultVariableValueSingle()); - -export type VariableValueSingle = string | boolean | number | CustomVariableValue; - -export const defaultVariableValueSingle = (): VariableValueSingle => (""); - -// Custom formatter variable -export interface CustomFormatterVariable { - name: string; - type: VariableType; - multi: boolean; - includeAll: boolean; -} - -export const defaultCustomFormatterVariable = (): CustomFormatterVariable => ({ - name: "", - type: "query", - multi: false, - includeAll: false, -}); - -// Custom variable value -export interface CustomVariableValue { - // The format name or function used in the expression - formatter: string | VariableCustomFormatterFn; -} - -export const defaultCustomVariableValue = (): CustomVariableValue => ({ - formatter: "", -}); - -// Custom formatter function -export interface VariableCustomFormatterFn { - value: any; - legacyVariableModel: { - name: string; - type: VariableType; - multi: boolean; - includeAll: boolean; - }; - legacyDefaultFormatter?: VariableCustomFormatterFn; -} - -export const defaultVariableCustomFormatterFn = (): VariableCustomFormatterFn => ({ - value: {}, - legacyVariableModel: { - name: "", - type: "query", - multi: false, - includeAll: false, -}, -}); - -// Dashboard variable type -// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. -// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). -// `constant`: Define a hidden constant. -// `datasource`: Quickly change the data source for an entire dashboard. -// `interval`: Interval variables represent time spans. -// `textbox`: Display a free text input field with an optional default value. -// `custom`: Define the variable options manually using a comma-separated list. -// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables -export type VariableType = "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | "system" | "snapshot"; - -export const defaultVariableType = (): VariableType => ("query"); - -export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind; - -export const defaultVariableKind = (): VariableKind => (defaultQueryVariableKind()); - -// Sort variable options -// Accepted values are: -// `disabled`: No sorting -// `alphabeticalAsc`: Alphabetical ASC -// `alphabeticalDesc`: Alphabetical DESC -// `numericalAsc`: Numerical ASC -// `numericalDesc`: Numerical DESC -// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC -// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC -// `naturalAsc`: Natural ASC -// `naturalDesc`: Natural DESC -// VariableSort enum with default value -export type VariableSort = "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc"; - -export const defaultVariableSort = (): VariableSort => ("disabled"); - -// Options to config when to refresh a variable -// `never`: Never refresh the variable -// `onDashboardLoad`: Queries the data source every time the dashboard loads. -// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes. -export type VariableRefresh = "never" | "onDashboardLoad" | "onTimeRangeChanged"; - -export const defaultVariableRefresh = (): VariableRefresh => ("never"); - -// Determine if the variable shows on dashboard -// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). -export type VariableHide = "dontHide" | "hideLabel" | "hideVariable"; - -export const defaultVariableHide = (): VariableHide => ("dontHide"); - -// FIXME: should we introduce this? --- Variable value option -export interface VariableValueOption { - label: string; - value: VariableValueSingle; - group?: string; -} - -export const defaultVariableValueOption = (): VariableValueOption => ({ - label: "", - value: defaultVariableValueSingle(), -}); - -// Variable option specification -export interface VariableOption { - // Whether the option is selected or not - selected?: boolean; - // Text to be displayed for the option - text: string | string[]; - // Value of the option - value: string | string[]; -} - -export const defaultVariableOption = (): VariableOption => ({ - text: "", - value: "", -}); - -// Query variable specification -export interface QueryVariableSpec { - name: string; - current: VariableOption; - label?: string; - hide: VariableHide; - refresh: VariableRefresh; - skipUrlSync: boolean; - description?: string; - datasource?: DataSourceRef; - query: DataQueryKind; - regex: string; - sort: VariableSort; - definition?: string; - options: VariableOption[]; - multi: boolean; - includeAll: boolean; - allValue?: string; - placeholder?: string; -} - -export const defaultQueryVariableSpec = (): QueryVariableSpec => ({ - name: "", - current: { text: "", value: "", }, - hide: "dontHide", - refresh: "never", - skipUrlSync: false, - query: defaultDataQueryKind(), - regex: "", - sort: "disabled", - options: [], - multi: false, - includeAll: false, -}); - -// Query variable kind -export interface QueryVariableKind { - kind: "QueryVariable"; - spec: QueryVariableSpec; -} - -export const defaultQueryVariableKind = (): QueryVariableKind => ({ - kind: "QueryVariable", - spec: defaultQueryVariableSpec(), -}); - -// Text variable specification -export interface TextVariableSpec { - name: string; - current: VariableOption; - query: string; - label?: string; - hide: VariableHide; - skipUrlSync: boolean; - description?: string; -} - -export const defaultTextVariableSpec = (): TextVariableSpec => ({ - name: "", - current: { text: "", value: "", }, - query: "", - hide: "dontHide", - skipUrlSync: false, -}); - -// Text variable kind -export interface TextVariableKind { - kind: "TextVariable"; - spec: TextVariableSpec; -} - -export const defaultTextVariableKind = (): TextVariableKind => ({ - kind: "TextVariable", - spec: defaultTextVariableSpec(), -}); - -// Constant variable specification -export interface ConstantVariableSpec { - name: string; - query: string; - current: VariableOption; - label?: string; - hide: VariableHide; - skipUrlSync: boolean; - description?: string; -} - -export const defaultConstantVariableSpec = (): ConstantVariableSpec => ({ - name: "", - query: "", - current: { text: "", value: "", }, - hide: "dontHide", - skipUrlSync: false, -}); - -// Constant variable kind -export interface ConstantVariableKind { - kind: "ConstantVariable"; - spec: ConstantVariableSpec; -} - -export const defaultConstantVariableKind = (): ConstantVariableKind => ({ - kind: "ConstantVariable", - spec: defaultConstantVariableSpec(), -}); - -// Datasource variable specification -export interface DatasourceVariableSpec { - name: string; - pluginId: string; - refresh: VariableRefresh; - regex: string; - current: VariableOption; - options: VariableOption[]; - multi: boolean; - includeAll: boolean; - allValue?: string; - label?: string; - hide: VariableHide; - skipUrlSync: boolean; - description?: string; -} - -export const defaultDatasourceVariableSpec = (): DatasourceVariableSpec => ({ - name: "", - pluginId: "", - refresh: "never", - regex: "", - current: { text: "", value: "", }, - options: [], - multi: false, - includeAll: false, - hide: "dontHide", - skipUrlSync: false, -}); - -// Datasource variable kind -export interface DatasourceVariableKind { - kind: "DatasourceVariable"; - spec: DatasourceVariableSpec; -} - -export const defaultDatasourceVariableKind = (): DatasourceVariableKind => ({ - kind: "DatasourceVariable", - spec: defaultDatasourceVariableSpec(), -}); - -// Interval variable specification -export interface IntervalVariableSpec { - name: string; - query: string; - current: VariableOption; - options: VariableOption[]; - auto: boolean; - auto_min: string; - auto_count: number; - refresh: VariableRefresh; - label?: string; - hide: VariableHide; - skipUrlSync: boolean; - description?: string; -} - -export const defaultIntervalVariableSpec = (): IntervalVariableSpec => ({ - name: "", - query: "", - current: { text: "", value: "", }, - options: [], - auto: false, - auto_min: "", - auto_count: 0, - refresh: "never", - hide: "dontHide", - skipUrlSync: false, -}); - -// Interval variable kind -export interface IntervalVariableKind { - kind: "IntervalVariable"; - spec: IntervalVariableSpec; -} - -export const defaultIntervalVariableKind = (): IntervalVariableKind => ({ - kind: "IntervalVariable", - spec: defaultIntervalVariableSpec(), -}); - -// Custom variable specification -export interface CustomVariableSpec { - name: string; - query: string; - current: VariableOption; - options: VariableOption[]; - multi: boolean; - includeAll: boolean; - allValue?: string; - label?: string; - hide: VariableHide; - skipUrlSync: boolean; - description?: string; -} - -export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ - name: "", - query: "", - current: defaultVariableOption(), - options: [], - multi: false, - includeAll: false, - hide: "dontHide", - skipUrlSync: false, -}); - -// Custom variable kind -export interface CustomVariableKind { - kind: "CustomVariable"; - spec: CustomVariableSpec; -} - -export const defaultCustomVariableKind = (): CustomVariableKind => ({ - kind: "CustomVariable", - spec: defaultCustomVariableSpec(), -}); - -// GroupBy variable specification -export interface GroupByVariableSpec { - name: string; - datasource?: DataSourceRef; - current: VariableOption; - options: VariableOption[]; - multi: boolean; - label?: string; - hide: VariableHide; - skipUrlSync: boolean; - description?: string; -} - -export const defaultGroupByVariableSpec = (): GroupByVariableSpec => ({ - name: "", - current: { text: "", value: "", }, - options: [], - multi: false, - hide: "dontHide", - skipUrlSync: false, -}); - -// Group variable kind -export interface GroupByVariableKind { - kind: "GroupByVariable"; - spec: GroupByVariableSpec; -} - -export const defaultGroupByVariableKind = (): GroupByVariableKind => ({ - kind: "GroupByVariable", - spec: defaultGroupByVariableSpec(), -}); - -// Adhoc variable specification -export interface AdhocVariableSpec { - name: string; - datasource?: DataSourceRef; - baseFilters: AdHocFilterWithLabels[]; - filters: AdHocFilterWithLabels[]; - defaultKeys: MetricFindValue[]; - label?: string; - hide: VariableHide; - skipUrlSync: boolean; - description?: string; -} - -export const defaultAdhocVariableSpec = (): AdhocVariableSpec => ({ - name: "", - baseFilters: [], - filters: [], - defaultKeys: [], - hide: "dontHide", - skipUrlSync: false, -}); - -// Define the MetricFindValue type -export interface MetricFindValue { - text: string; - value?: string | number; - group?: string; - expandable?: boolean; -} - -export const defaultMetricFindValue = (): MetricFindValue => ({ - text: "", -}); - -// Define the AdHocFilterWithLabels type -export interface AdHocFilterWithLabels { - key: string; - operator: string; - value: string; - values?: string[]; - keyLabel?: string; - valueLabels?: string[]; - forceEdit?: boolean; - // @deprecated - condition?: string; -} - -export const defaultAdHocFilterWithLabels = (): AdHocFilterWithLabels => ({ - key: "", - operator: "", - value: "", -}); - -// Adhoc variable kind -export interface AdhocVariableKind { - kind: "AdhocVariable"; - spec: AdhocVariableSpec; -} - -export const defaultAdhocVariableKind = (): AdhocVariableKind => ({ - kind: "AdhocVariable", - spec: defaultAdhocVariableSpec(), -}); - export interface ConditionalRenderingGroupKind { kind: "ConditionalRenderingGroup"; spec: ConditionalRenderingGroupSpec; @@ -1481,3 +813,671 @@ export const defaultConditionalRenderingTimeIntervalSpec = (): ConditionalRender value: "", }); +export interface ResponsiveGridLayoutKind { + kind: "ResponsiveGridLayout"; + spec: ResponsiveGridLayoutSpec; +} + +export const defaultResponsiveGridLayoutKind = (): ResponsiveGridLayoutKind => ({ + kind: "ResponsiveGridLayout", + spec: defaultResponsiveGridLayoutSpec(), +}); + +export interface ResponsiveGridLayoutSpec { + row: string; + col: string; + items: ResponsiveGridLayoutItemKind[]; +} + +export const defaultResponsiveGridLayoutSpec = (): ResponsiveGridLayoutSpec => ({ + row: "", + col: "", + items: [], +}); + +export interface ResponsiveGridLayoutItemKind { + kind: "ResponsiveGridLayoutItem"; + spec: ResponsiveGridLayoutItemSpec; +} + +export const defaultResponsiveGridLayoutItemKind = (): ResponsiveGridLayoutItemKind => ({ + kind: "ResponsiveGridLayoutItem", + spec: defaultResponsiveGridLayoutItemSpec(), +}); + +export interface ResponsiveGridLayoutItemSpec { + element: ElementReference; + repeat?: ResponsiveGridRepeatOptions; + conditionalRendering?: ConditionalRenderingGroupKind; +} + +export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({ + element: defaultElementReference(), +}); + +export interface ResponsiveGridRepeatOptions { + mode: "variable"; + value: string; +} + +export const defaultResponsiveGridRepeatOptions = (): ResponsiveGridRepeatOptions => ({ + mode: RepeatMode, + value: "", +}); + +export interface TabsLayoutKind { + kind: "TabsLayout"; + spec: TabsLayoutSpec; +} + +export const defaultTabsLayoutKind = (): TabsLayoutKind => ({ + kind: "TabsLayout", + spec: defaultTabsLayoutSpec(), +}); + +export interface TabsLayoutSpec { + tabs: TabsLayoutTabKind[]; +} + +export const defaultTabsLayoutSpec = (): TabsLayoutSpec => ({ + tabs: [], +}); + +export interface TabsLayoutTabKind { + kind: "TabsLayoutTab"; + spec: TabsLayoutTabSpec; +} + +export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({ + kind: "TabsLayoutTab", + spec: defaultTabsLayoutTabSpec(), +}); + +export interface TabsLayoutTabSpec { + title?: string; + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind; +} + +export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({ + layout: defaultGridLayoutKind(), +}); + +// Links with references to other dashboards or external resources +export interface DashboardLink { + // Title to display with the link + title: string; + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType` + type: DashboardLinkType; + // Icon name to be displayed with the link + icon: string; + // Tooltip to display when the user hovers their mouse over it + tooltip: string; + // Link URL. Only required/valid if the type is link + url?: string; + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: string[]; + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: boolean; + // If true, the link will be opened in a new tab + targetBlank: boolean; + // If true, includes current template variables values in the link as query params + includeVars: boolean; + // If true, includes current time range in the link as query params + keepTime: boolean; +} + +export const defaultDashboardLink = (): DashboardLink => ({ + title: "", + type: "link", + icon: "", + tooltip: "", + tags: [], + asDropdown: false, + targetBlank: false, + includeVars: false, + keepTime: false, +}); + +// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) +export type DashboardLinkType = "link" | "dashboards"; + +export const defaultDashboardLinkType = (): DashboardLinkType => ("link"); + +// Time configuration +// It defines the default time config for the time picker, the refresh picker for the specific dashboard. +export interface TimeSettingsSpec { + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string; + // Start time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + from: string; + // End time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + to: string; + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + // v1: refresh + autoRefresh: string; + // Interval options available in the refresh picker dropdown. + // v1: timepicker.refresh_intervals + autoRefreshIntervals: string[]; + // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + // v1: timepicker.quick_ranges , not exposed in the UI + quickRanges?: TimeRangeOption[]; + // Whether timepicker is visible or not. + // v1: timepicker.hidden + hideTimepicker: boolean; + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart?: "saturday" | "monday" | "sunday"; + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth: number; + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + // v1: timepicker.nowDelay + nowDelay?: string; +} + +export const defaultTimeSettingsSpec = (): TimeSettingsSpec => ({ + timezone: "browser", + from: "now-6h", + to: "now", + autoRefresh: "", + autoRefreshIntervals: [ +"5s", +"10s", +"30s", +"1m", +"5m", +"15m", +"30m", +"1h", +"2h", +"1d", +], + hideTimepicker: false, + fiscalYearStartMonth: 0, +}); + +export interface TimeRangeOption { + display: string; + from: string; + to: string; +} + +export const defaultTimeRangeOption = (): TimeRangeOption => ({ + display: "Last 6 hours", + from: "now-6h", + to: "now", +}); + +export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind; + +export const defaultVariableKind = (): VariableKind => (defaultQueryVariableKind()); + +// Query variable kind +export interface QueryVariableKind { + kind: "QueryVariable"; + spec: QueryVariableSpec; +} + +export const defaultQueryVariableKind = (): QueryVariableKind => ({ + kind: "QueryVariable", + spec: defaultQueryVariableSpec(), +}); + +// Query variable specification +export interface QueryVariableSpec { + name: string; + current: VariableOption; + label?: string; + hide: VariableHide; + refresh: VariableRefresh; + skipUrlSync: boolean; + description?: string; + datasource?: DataSourceRef; + query: DataQueryKind; + regex: string; + sort: VariableSort; + definition?: string; + options: VariableOption[]; + multi: boolean; + includeAll: boolean; + allValue?: string; + placeholder?: string; +} + +export const defaultQueryVariableSpec = (): QueryVariableSpec => ({ + name: "", + current: { text: "", value: "", }, + hide: "dontHide", + refresh: "never", + skipUrlSync: false, + query: defaultDataQueryKind(), + regex: "", + sort: "disabled", + options: [], + multi: false, + includeAll: false, +}); + +// Variable option specification +export interface VariableOption { + // Whether the option is selected or not + selected?: boolean; + // Text to be displayed for the option + text: string | string[]; + // Value of the option + value: string | string[]; +} + +export const defaultVariableOption = (): VariableOption => ({ + text: "", + value: "", +}); + +// Determine if the variable shows on dashboard +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). +export type VariableHide = "dontHide" | "hideLabel" | "hideVariable"; + +export const defaultVariableHide = (): VariableHide => ("dontHide"); + +// Options to config when to refresh a variable +// `never`: Never refresh the variable +// `onDashboardLoad`: Queries the data source every time the dashboard loads. +// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes. +export type VariableRefresh = "never" | "onDashboardLoad" | "onTimeRangeChanged"; + +export const defaultVariableRefresh = (): VariableRefresh => ("never"); + +// Sort variable options +// Accepted values are: +// `disabled`: No sorting +// `alphabeticalAsc`: Alphabetical ASC +// `alphabeticalDesc`: Alphabetical DESC +// `numericalAsc`: Numerical ASC +// `numericalDesc`: Numerical DESC +// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC +// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC +// `naturalAsc`: Natural ASC +// `naturalDesc`: Natural DESC +// VariableSort enum with default value +export type VariableSort = "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc"; + +export const defaultVariableSort = (): VariableSort => ("disabled"); + +// Text variable kind +export interface TextVariableKind { + kind: "TextVariable"; + spec: TextVariableSpec; +} + +export const defaultTextVariableKind = (): TextVariableKind => ({ + kind: "TextVariable", + spec: defaultTextVariableSpec(), +}); + +// Text variable specification +export interface TextVariableSpec { + name: string; + current: VariableOption; + query: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultTextVariableSpec = (): TextVariableSpec => ({ + name: "", + current: { text: "", value: "", }, + query: "", + hide: "dontHide", + skipUrlSync: false, +}); + +// Constant variable kind +export interface ConstantVariableKind { + kind: "ConstantVariable"; + spec: ConstantVariableSpec; +} + +export const defaultConstantVariableKind = (): ConstantVariableKind => ({ + kind: "ConstantVariable", + spec: defaultConstantVariableSpec(), +}); + +// Constant variable specification +export interface ConstantVariableSpec { + name: string; + query: string; + current: VariableOption; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultConstantVariableSpec = (): ConstantVariableSpec => ({ + name: "", + query: "", + current: { text: "", value: "", }, + hide: "dontHide", + skipUrlSync: false, +}); + +// Datasource variable kind +export interface DatasourceVariableKind { + kind: "DatasourceVariable"; + spec: DatasourceVariableSpec; +} + +export const defaultDatasourceVariableKind = (): DatasourceVariableKind => ({ + kind: "DatasourceVariable", + spec: defaultDatasourceVariableSpec(), +}); + +// Datasource variable specification +export interface DatasourceVariableSpec { + name: string; + pluginId: string; + refresh: VariableRefresh; + regex: string; + current: VariableOption; + options: VariableOption[]; + multi: boolean; + includeAll: boolean; + allValue?: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultDatasourceVariableSpec = (): DatasourceVariableSpec => ({ + name: "", + pluginId: "", + refresh: "never", + regex: "", + current: { text: "", value: "", }, + options: [], + multi: false, + includeAll: false, + hide: "dontHide", + skipUrlSync: false, +}); + +// Interval variable kind +export interface IntervalVariableKind { + kind: "IntervalVariable"; + spec: IntervalVariableSpec; +} + +export const defaultIntervalVariableKind = (): IntervalVariableKind => ({ + kind: "IntervalVariable", + spec: defaultIntervalVariableSpec(), +}); + +// Interval variable specification +export interface IntervalVariableSpec { + name: string; + query: string; + current: VariableOption; + options: VariableOption[]; + auto: boolean; + auto_min: string; + auto_count: number; + refresh: VariableRefresh; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultIntervalVariableSpec = (): IntervalVariableSpec => ({ + name: "", + query: "", + current: { text: "", value: "", }, + options: [], + auto: false, + auto_min: "", + auto_count: 0, + refresh: "never", + hide: "dontHide", + skipUrlSync: false, +}); + +// Custom variable kind +export interface CustomVariableKind { + kind: "CustomVariable"; + spec: CustomVariableSpec; +} + +export const defaultCustomVariableKind = (): CustomVariableKind => ({ + kind: "CustomVariable", + spec: defaultCustomVariableSpec(), +}); + +// Custom variable specification +export interface CustomVariableSpec { + name: string; + query: string; + current: VariableOption; + options: VariableOption[]; + multi: boolean; + includeAll: boolean; + allValue?: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ + name: "", + query: "", + current: defaultVariableOption(), + options: [], + multi: false, + includeAll: false, + hide: "dontHide", + skipUrlSync: false, +}); + +// Group variable kind +export interface GroupByVariableKind { + kind: "GroupByVariable"; + spec: GroupByVariableSpec; +} + +export const defaultGroupByVariableKind = (): GroupByVariableKind => ({ + kind: "GroupByVariable", + spec: defaultGroupByVariableSpec(), +}); + +// GroupBy variable specification +export interface GroupByVariableSpec { + name: string; + datasource?: DataSourceRef; + current: VariableOption; + options: VariableOption[]; + multi: boolean; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultGroupByVariableSpec = (): GroupByVariableSpec => ({ + name: "", + current: { text: "", value: "", }, + options: [], + multi: false, + hide: "dontHide", + skipUrlSync: false, +}); + +// Adhoc variable kind +export interface AdhocVariableKind { + kind: "AdhocVariable"; + spec: AdhocVariableSpec; +} + +export const defaultAdhocVariableKind = (): AdhocVariableKind => ({ + kind: "AdhocVariable", + spec: defaultAdhocVariableSpec(), +}); + +// Adhoc variable specification +export interface AdhocVariableSpec { + name: string; + datasource?: DataSourceRef; + baseFilters: AdHocFilterWithLabels[]; + filters: AdHocFilterWithLabels[]; + defaultKeys: MetricFindValue[]; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultAdhocVariableSpec = (): AdhocVariableSpec => ({ + name: "", + baseFilters: [], + filters: [], + defaultKeys: [], + hide: "dontHide", + skipUrlSync: false, +}); + +// Define the AdHocFilterWithLabels type +export interface AdHocFilterWithLabels { + key: string; + operator: string; + value: string; + values?: string[]; + keyLabel?: string; + valueLabels?: string[]; + forceEdit?: boolean; + // @deprecated + condition?: string; +} + +export const defaultAdHocFilterWithLabels = (): AdHocFilterWithLabels => ({ + key: "", + operator: "", + value: "", +}); + +// Define the MetricFindValue type +export interface MetricFindValue { + text: string; + value?: string | number; + group?: string; + expandable?: boolean; +} + +export const defaultMetricFindValue = (): MetricFindValue => ({ + text: "", +}); + +// Supported value mapping types +// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. +export type MappingType = "value" | "range" | "regex" | "special"; + +export const defaultMappingType = (): MappingType => ("value"); + +// --- Common types --- +export interface Kind { + kind: string; + spec: any; + metadata?: any; +} + +export const defaultKind = (): Kind => ({ + kind: "", + spec: {}, +}); + +// Variable types +export type VariableValue = VariableValueSingle | VariableValueSingle[]; + +export const defaultVariableValue = (): VariableValue => (defaultVariableValueSingle()); + +export type VariableValueSingle = string | boolean | number | CustomVariableValue; + +export const defaultVariableValueSingle = (): VariableValueSingle => (""); + +// Custom variable value +export interface CustomVariableValue { + // The format name or function used in the expression + formatter: string | VariableCustomFormatterFn; +} + +export const defaultCustomVariableValue = (): CustomVariableValue => ({ + formatter: "", +}); + +// Custom formatter function +export interface VariableCustomFormatterFn { + value: any; + legacyVariableModel: { + name: string; + type: VariableType; + multi: boolean; + includeAll: boolean; + }; + legacyDefaultFormatter?: VariableCustomFormatterFn; +} + +export const defaultVariableCustomFormatterFn = (): VariableCustomFormatterFn => ({ + value: {}, + legacyVariableModel: { + name: "", + type: "query", + multi: false, + includeAll: false, +}, +}); + +// Dashboard variable type +// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. +// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). +// `constant`: Define a hidden constant. +// `datasource`: Quickly change the data source for an entire dashboard. +// `interval`: Interval variables represent time spans. +// `textbox`: Display a free text input field with an optional default value. +// `custom`: Define the variable options manually using a comma-separated list. +// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables +export type VariableType = "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | "system" | "snapshot"; + +export const defaultVariableType = (): VariableType => ("query"); + +// Custom formatter variable +export interface CustomFormatterVariable { + name: string; + type: VariableType; + multi: boolean; + includeAll: boolean; +} + +export const defaultCustomFormatterVariable = (): CustomFormatterVariable => ({ + name: "", + type: "query", + multi: false, + includeAll: false, +}); + +// FIXME: should we introduce this? --- Variable value option +export interface VariableValueOption { + label: string; + value: VariableValueSingle; + group?: string; +} + +export const defaultVariableValueOption = (): VariableValueOption => ({ + label: "", + value: defaultVariableValueSingle(), +}); + diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 387fcf41dca..fbb8f414e4d 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -99,6 +99,10 @@ export class DashboardEditPane extends SceneObjectBase { } } + public getSelection(): SceneObject | SceneObject[] | undefined { + return this.state.selection?.getSelection(); + } + public selectObject(obj: SceneObject, id: string, multi?: boolean) { const prevItem = this.state.selection?.getFirstObject(); if (prevItem === obj && !multi) { diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 16645157dfa..b2de2c14ca0 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -75,7 +75,7 @@ import { LayoutOrchestrator } from './layout-manager/LayoutOrchestrator'; import { LayoutRestorer } from './layouts-shared/LayoutRestorer'; import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; -import { LayoutParent } from './types/LayoutParent'; +import { isLayoutParent, LayoutParent } from './types/LayoutParent'; export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta', 'preload']; export const PANEL_SEARCH_VAR = 'systemPanelFilterVar'; @@ -500,6 +500,13 @@ export class DashboardScene extends SceneObjectBase impleme this.onEnterEditMode(); } + const selectedObject = this.state.editPane.getSelection(); + if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) { + const layout = selectedObject.getLayout(); + layout.addPanel(vizPanel); + return; + } + // Add panel to layout this.state.body.addPanel(vizPanel); } @@ -609,10 +616,22 @@ export class DashboardScene extends SceneObjectBase impleme } public onCreateNewRow() { + const selectedObject = this.state.editPane.getSelection(); + if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) { + const layout = selectedObject.getLayout(); + return addNewRowTo(layout); + } + return addNewRowTo(this.state.body); } public onCreateNewTab() { + const selectedObject = this.state.editPane.getSelection(); + if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) { + const layout = selectedObject.getLayout(); + return addNewTabTo(layout); + } + return addNewTabTo(this.state.body); } 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 f8cb979f064..3628ea6f026 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -256,6 +256,7 @@ export class DefaultGridLayoutManager sceneGridLayout.setState({ children: [row, ...sceneGridLayout.state.children] }); + this.publishEvent(new NewObjectAddedToCanvasEvent(row), true); return row; } 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 7ad15344c45..15e3efbcd31 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -89,6 +89,7 @@ export class RowsLayoutManager extends SceneObjectBase i public addNewRow(): RowItem { const row = new RowItem(); this.setState({ rows: [...this.state.rows, row] }); + this.publishEvent(new NewObjectAddedToCanvasEvent(row), true); return row; } @@ -112,7 +113,7 @@ export class RowsLayoutManager extends SceneObjectBase i }); } - public addRowAbove(row: RowItem) { + public addRowAbove(row: RowItem): RowItem { const index = this.state.rows.indexOf(row); const newRow = new RowItem(); const newRows = [...this.state.rows]; @@ -121,9 +122,11 @@ export class RowsLayoutManager extends SceneObjectBase i this.setState({ rows: newRows }); this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true); + + return newRow; } - public addRowBelow(row: RowItem) { + public addRowBelow(row: RowItem): RowItem { const rows = this.state.rows; let index = rows.indexOf(row); @@ -139,6 +142,8 @@ export class RowsLayoutManager extends SceneObjectBase i this.setState({ rows: newRows }); this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true); + + return newRow; } public removeRow(row: RowItem) { 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 6478ff19c54..71af05bfce7 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -110,6 +110,7 @@ export class TabsLayoutManager extends SceneObjectBase i public addNewTab() { const newTab = new TabItem(); this.setState({ tabs: [...this.state.tabs, newTab], currentTabIndex: this.state.tabs.length }); + this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true); return newTab; } @@ -143,20 +144,24 @@ export class TabsLayoutManager extends SceneObjectBase i this.publishEvent(new ObjectRemovedFromCanvasEvent(tabToRemove), true); } - public addTabBefore(tab: TabItem) { + public addTabBefore(tab: TabItem): TabItem { const newTab = new TabItem(); const tabs = this.state.tabs.slice(); tabs.splice(tabs.indexOf(tab), 0, newTab); this.setState({ tabs, currentTabIndex: this.state.currentTabIndex }); this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true); + + return newTab; } - public addTabAfter(tab: TabItem) { + public addTabAfter(tab: TabItem): TabItem { const newTab = new TabItem(); const tabs = this.state.tabs.slice(); tabs.splice(tabs.indexOf(tab) + 1, 0, newTab); this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 }); this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true); + + return newTab; } public moveTabLeft(tab: TabItem) { diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/addNew.ts b/public/app/features/dashboard-scene/scene/layouts-shared/addNew.ts index 0a844199114..d7c139d1716 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/addNew.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/addNew.ts @@ -1,5 +1,5 @@ import { config } from '@grafana/runtime'; -import { SceneGridRow } from '@grafana/scenes'; +import { sceneGraph, SceneGridRow } from '@grafana/scenes'; import { NewObjectAddedToCanvasEvent } from '../../edit-pane/shared'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; @@ -11,18 +11,25 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { isLayoutParent } from '../types/LayoutParent'; export function addNewTabTo(layout: DashboardLayoutManager): TabItem { - if (layout instanceof TabsLayoutManager) { - const tab = layout.addNewTab(); - layout.publishEvent(new NewObjectAddedToCanvasEvent(tab), true); - return tab; - } - const layoutParent = layout.parent!; if (!isLayoutParent(layoutParent)) { throw new Error('Parent layout is not a LayoutParent'); } - const tabsLayout = TabsLayoutManager.createFromLayout(layoutParent.getLayout()); + // If layout parent is tab item we add new tab after it rather than create a nested tab + if (layoutParent instanceof TabItem) { + const tabsLayout = sceneGraph.getAncestor(layoutParent, TabsLayoutManager); + return tabsLayout.addTabAfter(layoutParent); + } + + if (layout instanceof TabsLayoutManager) { + return layout.addNewTab(); + } + + // Create new tabs layout and wrap the current layout in the first tab + const tabsLayout = TabsLayoutManager.createEmpty(); + tabsLayout.state.tabs[0].setState({ layout: layout.clone() }); + layoutParent.switchLayout(tabsLayout); const tab = tabsLayout.state.tabs[0]; @@ -37,18 +44,25 @@ export function addNewRowTo(layout: DashboardLayoutManager): RowItem | SceneGrid */ if (!config.featureToggles.dashboardNewLayouts) { if (layout instanceof DefaultGridLayoutManager) { - const row = layout.addNewRow(); - layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true); - return row; + return layout.addNewRow(); } else { throw new Error('New dashboard layouts feature not enabled but new layout found'); } } + const layoutParent = layout.parent!; + if (!isLayoutParent(layoutParent)) { + throw new Error('Parent layout is not a LayoutParent'); + } + + // If adding we are adding a row to a row we add it below the current row + if (layoutParent instanceof RowItem) { + const rowsLayout = sceneGraph.getAncestor(layoutParent, RowsLayoutManager); + return rowsLayout.addRowBelow(layoutParent); + } + if (layout instanceof RowsLayoutManager) { - const row = layout.addNewRow(); - layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true); - return row; + return layout.addNewRow(); } if (layout instanceof TabsLayoutManager) { @@ -59,11 +73,6 @@ export function addNewRowTo(layout: DashboardLayoutManager): RowItem | SceneGrid // If we want to add a row and current layout is custom grid or auto we migrate to rows layout // And wrap current layout in a row - const layoutParent = layout.parent!; - if (!isLayoutParent(layoutParent)) { - throw new Error('Parent layout is not a LayoutParent'); - } - const rowsLayout = RowsLayoutManager.createFromLayout(layoutParent.getLayout()); layoutParent.switchLayout(rowsLayout); diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts index ef3402cc304..c403b5a3d7a 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts @@ -16,9 +16,6 @@ export class RowsLayoutSerializer implements LayoutManagerSerializer { spec: { rows: layoutManager.state.rows.map((row) => { const layout = getLayout(row.state.layout); - if (layout.kind === 'RowsLayout') { - throw new Error('Nested RowsLayout is not supported'); - } const rowKind: RowsLayoutRowKind = { kind: 'RowsLayoutRow', spec: { diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts index d5093fb4663..c3f7eedc9db 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts @@ -14,9 +14,6 @@ export class TabsLayoutSerializer implements LayoutManagerSerializer { spec: { tabs: layoutManager.state.tabs.map((tab) => { const layout = getLayout(tab.state.layout); - if (layout.kind === 'TabsLayout') { - throw new Error('Nested TabsLayout is not supported'); - } return { kind: 'TabsLayoutTab', spec: { From 2aae523c3f0fbf3690a30c23578170b6c515e374 Mon Sep 17 00:00:00 2001 From: Scott Lepper Date: Fri, 21 Mar 2025 09:57:20 -0400 Subject: [PATCH 44/79] Search: Fix title filter overmatching (#102547) * fix issues with over matching * search wildcard for backward compatibility --- pkg/storage/unified/search/bleve.go | 102 +++++++-- pkg/storage/unified/search/bleve_mappings.go | 16 +- .../unified/search/bleve_mappings_test.go | 7 +- .../unified/search/bleve_performance_test.go | 131 ++++++++++++ .../unified/search/bleve_search_test.go | 198 +++++++++++++++--- pkg/storage/unified/search/document_test.go | 11 +- 6 files changed, 403 insertions(+), 62 deletions(-) create mode 100644 pkg/storage/unified/search/bleve_performance_test.go diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 4976449f548..e99df0870e3 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -159,7 +159,7 @@ func (b *bleveBackend) BuildIndex(ctx context.Context, var index bleve.Index build := true - mapper, err := getBleveMappings(fields) + mapper, err := GetBleveMappings(fields) if err != nil { return nil, err } @@ -660,8 +660,14 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res } } - // Add a text query - if req.Query != "" && req.Query != "*" { + if len(req.Query) > 1 && strings.Contains(req.Query, "*") { + // wildcard query is expensive - should be used with caution + wildcard := bleve.NewWildcardQuery(req.Query) + queries = append(queries, wildcard) + } + + if req.Query != "" && !strings.Contains(req.Query, "*") { + // Add a text query searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE) // There are multiple ways to match the query string to documents. The following queries are ordered by priority: @@ -789,6 +795,11 @@ var textSortFields = map[string]string{ const lowerCase = "phrase" +// termField fields to use termQuery for filtering +var termFields = []string{ + resource.SEARCH_FIELD_TITLE, +} + // Convert a "requirement" into a bleve query func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *resource.ErrorResult) { switch selection.Operator(req.Operator) { @@ -797,16 +808,14 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r return query.NewMatchAllQuery(), nil } - if len(req.Values[0]) == 1 { - q := query.NewMatchQuery(filterValue(req.Key, req.Values[0])) - q.FieldVal = prefix + req.Key - return q, nil + if len(req.Values) == 1 { + filter := filterValue(req.Key, req.Values[0]) + return newQuery(req.Key, filter, prefix), nil } conjuncts := []query.Query{} for _, v := range req.Values { - q := query.NewMatchQuery(filterValue(req.Key, v)) - q.FieldVal = prefix + req.Key + q := newQuery(req.Key, filterValue(req.Key, v), prefix) conjuncts = append(conjuncts, q) } @@ -822,15 +831,13 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r return query.NewMatchAllQuery(), nil } if len(req.Values) == 1 { - q := query.NewMatchQuery(filterValue(req.Key, req.Values[0])) - q.FieldVal = prefix + req.Key + q := newQuery(req.Key, filterValue(req.Key, req.Values[0]), prefix) return q, nil } disjuncts := []query.Query{} for _, v := range req.Values { - q := query.NewMatchQuery(filterValue(req.Key, v)) - q.FieldVal = prefix + req.Key + q := newQuery(req.Key, filterValue(req.Key, v), prefix) disjuncts = append(disjuncts, q) } @@ -841,7 +848,8 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r var mustNotQueries []query.Query for _, value := range req.Values { - mustNotQueries = append(mustNotQueries, bleve.NewMatchQuery(filterValue(req.Key, value))) + q := newQuery(req.Key, filterValue(req.Key, value), prefix) + mustNotQueries = append(mustNotQueries, q) } boolQuery.AddMustNot(mustNotQueries...) @@ -856,6 +864,55 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r ) } +// newQuery will create a query that will match the value or the tokens of the value +func newQuery(key string, value string, prefix string) query.Query { + if value == "*" { + return bleve.NewMatchAllQuery() + } + if strings.Contains(value, "*") { + // wildcard query is expensive - should be used with caution + return bleve.NewWildcardQuery(value) + } + delimiter, ok := hasTerms(value) + if slices.Contains(termFields, key) && ok { + return newTermsQuery(key, value, delimiter, prefix) + } + q := bleve.NewMatchQuery(value) + q.SetField(prefix + key) + return q +} + +// newTermsQuery will create a query that will match on term or tokens +func newTermsQuery(key string, value string, delimiter string, prefix string) query.Query { + tokens := strings.Split(value, delimiter) + // won't match with ending space + value = strings.TrimSuffix(value, " ") + + q := bleve.NewTermQuery(value) + q.SetField(prefix + key) + + cq := newMatchAllTokensQuery(tokens, key, prefix) + return bleve.NewDisjunctionQuery(q, cq) +} + +// newMatchAllTokensQuery will create a query that will match on all tokens +func newMatchAllTokensQuery(tokens []string, key string, prefix string) query.Query { + cq := bleve.NewConjunctionQuery() + for _, token := range tokens { + _, ok := hasTerms(token) + if ok { + tq := bleve.NewTermQuery(token) + tq.SetField(prefix + key) + cq.AddQuery(tq) + continue + } + mq := bleve.NewMatchQuery(token) + mq.SetField(prefix + key) + cq.AddQuery(mq) + } + return cq +} + // filterValue will convert the value to lower case if the field is a phrase field func filterValue(field string, v string) string { if strings.HasSuffix(field, lowerCase) { @@ -1068,3 +1125,20 @@ func (q *permissionScopedQuery) Searcher(ctx context.Context, i index.IndexReade return filteringSearcher, nil } + +// hasTerms - any value that will be split into multiple tokens +var hasTerms = func(v string) (string, bool) { + for _, c := range TermCharacters { + if strings.Contains(v, c) { + return c, true + } + } + return "", false +} + +// TermCharacters characters that will be used to determine if a value is split into tokens +var TermCharacters = []string{ + " ", "-", "_", ".", ",", ":", ";", "?", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "+", + "=", "{", "}", "[", "]", "|", "\\", "/", "<", ">", "~", "`", + "'", "\"", +} diff --git a/pkg/storage/unified/search/bleve_mappings.go b/pkg/storage/unified/search/bleve_mappings.go index c9afce91a09..3a8c4e75420 100644 --- a/pkg/storage/unified/search/bleve_mappings.go +++ b/pkg/storage/unified/search/bleve_mappings.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" ) -func getBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { +func GetBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { mapper := bleve.NewIndexMapping() err := RegisterCustomAnalyzers(mapper) @@ -31,22 +31,22 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM } mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_NAME, nameMapping) + // for sorting by title full phrase + titlePhraseMapping := bleve.NewKeywordFieldMapping() + titlePhraseMapping.Store = false // already stored in title + mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_PHRASE, titlePhraseMapping) + // for searching by title - uses an edge ngram token filter titleSearchMapping := bleve.NewTextFieldMapping() titleSearchMapping.Analyzer = TITLE_ANALYZER titleSearchMapping.Store = false // already stored in title - mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_NGRAM, titleSearchMapping) // mapping for title to search on words/tokens larger than the ngram size titleWordMapping := bleve.NewTextFieldMapping() titleWordMapping.Analyzer = standard.Name titleWordMapping.Store = true - mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleWordMapping) - - // for filtering/sorting by title full phrase - titlePhraseMapping := bleve.NewKeywordFieldMapping() - titleSearchMapping.Store = false // already stored in title - mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_PHRASE, titlePhraseMapping) + // NOTE: this causes 3 title fields in the response + mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleWordMapping, titleSearchMapping, titlePhraseMapping) descriptionMapping := &mapping.FieldMapping{ Name: resource.SEARCH_FIELD_DESCRIPTION, diff --git a/pkg/storage/unified/search/bleve_mappings_test.go b/pkg/storage/unified/search/bleve_mappings_test.go index 9c1c6c7f8ad..3b8027ee06e 100644 --- a/pkg/storage/unified/search/bleve_mappings_test.go +++ b/pkg/storage/unified/search/bleve_mappings_test.go @@ -1,4 +1,4 @@ -package search +package search_test import ( "fmt" @@ -9,10 +9,11 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/search" ) func TestDocumentMapping(t *testing.T) { - mappings, err := getBleveMappings(nil) + mappings, err := search.GetBleveMappings(nil) require.NoError(t, err) data := resource.IndexableDocument{ Title: "title", @@ -48,5 +49,5 @@ func TestDocumentMapping(t *testing.T) { fmt.Printf("DOC: fields %d\n", len(doc.Fields)) fmt.Printf("DOC: size %d\n", doc.Size()) - require.Equal(t, 16, len(doc.Fields)) + require.Equal(t, 17, len(doc.Fields)) } diff --git a/pkg/storage/unified/search/bleve_performance_test.go b/pkg/storage/unified/search/bleve_performance_test.go new file mode 100644 index 00000000000..2d645235259 --- /dev/null +++ b/pkg/storage/unified/search/bleve_performance_test.go @@ -0,0 +1,131 @@ +package search_test + +import ( + "context" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/stretchr/testify/require" +) + +func setupIndex() (resource.ResourceIndex, string) { + // size := 1000000 // TODO: 200k documents standard size? + size := 200000 + // batchSize := 1000 slower 8s (for 200k documents) - 34s (for 1M documents) + // batchSize := 10000 // faster 5s (for 200k documents) - 27s (for 1M documents) + batchSize := 100000 // fasterer 3.5s (for 200k documents) - 27s (for 1M documents) + writer := newTestWriter(size, batchSize) + return newTestDashboardsIndex(nil, 1, int64(size), int64(batchSize), writer) +} + +const maxAllowedTime = 20 * time.Millisecond // Reasonable (can vary per env) performance threshold per query (e.g., 20ms) +const maxAllowedAllocMB = 1 // 1MB memory per operation +const maxAllowedAlloc = maxAllowedAllocMB * 1024 * 1024 +const verbose = false + +// BenchmarkBleveQuery measures the time, mem, cpu to execute a search query +// changes the the indexer settings can cause unforeseen performance issues ( for example: using wildcard queries ) +// this will fail if the stats exceed the "normal" thresholds +func BenchmarkBleveQuery(b *testing.B) { + var memStatsStart runtime.MemStats + var memStatsAfterIndex runtime.MemStats + runtime.ReadMemStats(&memStatsStart) + + testIndex, testIndexDir := setupIndex() + defer func() { + err := os.RemoveAll(testIndexDir) + if err != nil { + fmt.Printf("Error removing index directory: %v\n", err) + } + }() + + runtime.ReadMemStats(&memStatsAfterIndex) + + allocDiff := memStatsAfterIndex.Alloc - memStatsStart.Alloc + + logVerbose(fmt.Sprintf("Memory allocated for index: %d bytes", allocDiff)) + + searchRequest := newQueryByTitle("name99999") + + b.ResetTimer() // Reset timer before benchmarking + b.ReportAllocs() // Track memory allocations + + for i := 0; i < b.N; i++ { + start := time.Now() // Start timer + var memStatsBefore, memStatsAfter runtime.MemStats + runtime.ReadMemStats(&memStatsBefore) + + _, err := testIndex.Search(context.Background(), nil, searchRequest, nil) + + elapsed := time.Since(start) // Calculate elapsed time + runtime.ReadMemStats(&memStatsAfter) + allocDiff := (memStatsAfter.Alloc - memStatsBefore.Alloc) + if memStatsAfter.Alloc < memStatsBefore.Alloc { + // This can happen due to memory being freed after the search operation + allocDiff = 0 // don't care if it goes down + } + + logVerbose(fmt.Sprintf("Memory allocated for query: %d bytes", allocDiff)) + + require.NoError(b, err) + + // Fail if query takes longer than maxAllowedTime + if elapsed > maxAllowedTime { + b.Fatalf("Query too slow: %v (limit: %v)", elapsed, maxAllowedTime) + } + // Check memory allocation limit + if allocDiff > maxAllowedAlloc { + b.Fatalf("Excessive memory usage: %d mb (limit: %d mb)", allocDiff, maxAllowedAllocMB) + } + } +} + +func newTestWriter(size int, batchSize int) IndexWriter { + key := &resource.ResourceKey{ + Namespace: "default", + Group: "dashboard.grafana.app", + Resource: "dashboards", + } + + return func(index resource.ResourceIndex) (int64, error) { + total := time.Now() + start := time.Now() + for i := range size { + name := fmt.Sprintf("name%d", i) + err := index.Write(&resource.IndexableDocument{ + RV: int64(i), + Name: name, + Key: &resource.ResourceKey{ + Name: name, + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }, + Title: name + "-title", + }) + if err != nil { + return 0, err + } + // show progress for every batch + if i%batchSize == 0 && verbose { + fmt.Printf("Indexed %d documents\n", i) + end := time.Now() + fmt.Printf("Time taken for indexing batch: %s\n", end.Sub(start)) + start = time.Now() + } + } + end := time.Now() + logVerbose(fmt.Sprintf("Indexed %d documents in %s", size, end.Sub(total))) + return 0, nil + } +} + +func logVerbose(msg string) { + if verbose { + fmt.Println(msg) + } +} diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go index 29287de97e4..ae1782c61a2 100644 --- a/pkg/storage/unified/search/bleve_search_test.go +++ b/pkg/storage/unified/search/bleve_search_test.go @@ -1,4 +1,4 @@ -package search +package search_test import ( "context" @@ -16,8 +16,11 @@ import ( "github.com/grafana/grafana/pkg/services/store/kind/dashboard" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/search" ) +const threshold = 9999 + func TestCanSearchByTitle(t *testing.T) { key := &resource.ResourceKey{ Namespace: "default", @@ -26,7 +29,7 @@ func TestCanSearchByTitle(t *testing.T) { } t.Run("when query is empty, sort documents by title instead of search score", func(t *testing.T) { - index := newTestDashboardsIndex(t) + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.Write(&resource.IndexableDocument{ RV: 1, Name: "name1", @@ -53,7 +56,7 @@ func TestCanSearchByTitle(t *testing.T) { require.NoError(t, err) // search for phrase - query := newQuery("") + query := newTestQuery("") res, err := index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(2), res.TotalHits) @@ -61,7 +64,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("will boost phrase match query over match query results", func(t *testing.T) { - index := newTestDashboardsIndex(t) + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.Write(&resource.IndexableDocument{ RV: 1, Name: "name1", @@ -88,7 +91,7 @@ func TestCanSearchByTitle(t *testing.T) { require.NoError(t, err) // search for phrase - query := newQuery("want hello") + query := newTestQuery("want hello") res, err := index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(2), res.TotalHits) @@ -96,7 +99,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("will prioritize matches", func(t *testing.T) { - index := newTestDashboardsIndex(t) + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.Write(&resource.IndexableDocument{ RV: 1, Name: "name1", @@ -122,7 +125,7 @@ func TestCanSearchByTitle(t *testing.T) { }) require.NoError(t, err) - query := newQuery("New dash") + query := newTestQuery("New dash") res, err := index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(2), res.TotalHits) @@ -130,7 +133,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("will boost exact match query over match phrase query results", func(t *testing.T) { - index := newTestDashboardsIndex(t) + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.Write(&resource.IndexableDocument{ RV: 1, Name: "name1", @@ -157,7 +160,7 @@ func TestCanSearchByTitle(t *testing.T) { require.NoError(t, err) // search for exact match - query := newQuery("we want hello") + query := newTestQuery("we want hello") res, err := index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(2), res.TotalHits) @@ -165,7 +168,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("title with numbers will match document", func(t *testing.T) { - index := newTestDashboardsIndex(t) + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.Write(&resource.IndexableDocument{ RV: 1, Name: "name1", @@ -180,20 +183,65 @@ func TestCanSearchByTitle(t *testing.T) { require.NoError(t, err) // search for prefix of title with mix of chars and numbers - query := newQuery("A12") + query := newQueryByTitle("A12") res, err := index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) // search for whole title - query = newQuery("A123456") + query = newQueryByTitle("A123456") + res, err = index.Search(context.Background(), nil, query, nil) + require.NoError(t, err) + require.Equal(t, int64(1), res.TotalHits) + + // case insensive search for partial title + query = newQueryByTitle("a1234") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) }) + t.Run("title will match escaped characters", func(t *testing.T) { + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + err := index.Write(&resource.IndexableDocument{ + RV: 1, + Name: "name1", + Key: &resource.ResourceKey{ + Name: "aaa", + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }, + Title: "what\"s up", + }) + require.NoError(t, err) + + err = index.Write(&resource.IndexableDocument{ + RV: 2, + Name: "name2", + Key: &resource.ResourceKey{ + Name: "name2", + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }, + Title: "what\"s that", + }) + require.NoError(t, err) + + query := newQueryByTitle("what\"s up") + res, err := index.Search(context.Background(), nil, query, nil) + require.NoError(t, err) + require.Equal(t, int64(1), res.TotalHits) + + query = newQueryByTitle("what\"s") + res, err = index.Search(context.Background(), nil, query, nil) + require.NoError(t, err) + require.Equal(t, int64(2), res.TotalHits) + }) + t.Run("title search will match document", func(t *testing.T) { - index := newTestDashboardsIndex(t) + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.Write(&resource.IndexableDocument{ RV: 1, Name: "name1", @@ -208,50 +256,50 @@ func TestCanSearchByTitle(t *testing.T) { require.NoError(t, err) // search by entire phrase - query := newQuery("I want to say a wonderfully Hello to the WORLD! Hello-world") + query := newTestQuery("I want to say a wonderfully Hello to the WORLD! Hello-world") res, err := index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) // search for word at start - query = newQuery("hello") + query = newTestQuery("hello") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) // search for word larger than ngram max size - query = newQuery("wonderfully") + query = newQueryByTitle("wonderfully") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) // search for word at end - query = newQuery("world") + query = newQueryByTitle("world") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) // can search for word substring anchored at start of word (edge ngram) - query = newQuery("worl") + query = newQueryByTitle("worl") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) // can search for multiple, non-consecutive words in title - query = newQuery("hello world") + query = newQueryByTitle("hello world") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) // can search for a term with a hyphen - query = newQuery("hello-world") + query = newQueryByTitle("hello-world") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(1), res.TotalHits) }) t.Run("title search will NOT match documents", func(t *testing.T) { - index := newTestDashboardsIndex(t) + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.Write(&resource.IndexableDocument{ RV: 1, Name: "name1", @@ -290,26 +338,79 @@ func TestCanSearchByTitle(t *testing.T) { require.NoError(t, err) // word that doesn't exist - query := newQuery("cats") + query := newQueryByTitle("cats") res, err := index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(0), res.TotalHits) // string shorter than 3 chars (ngam min) - query = newQuery("ma") + query = newQueryByTitle("ma") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(0), res.TotalHits) // substring that doesn't exist - query = newQuery("A01") + query = newQueryByTitle("A01") res, err = index.Search(context.Background(), nil, query, nil) require.NoError(t, err) require.Equal(t, int64(0), res.TotalHits) }) + + t.Run("title search with character will match one document", func(t *testing.T) { + index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + err := index.Write(&resource.IndexableDocument{ + RV: 1, + Name: "name1", + Key: &resource.ResourceKey{ + Name: "aaa", + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }, + Title: "foo", + }) + require.NoError(t, err) + + for i, v := range search.TermCharacters { + err = index.Write(&resource.IndexableDocument{ + RV: int64(i), + Name: fmt.Sprintf("name%d", i), + Key: &resource.ResourceKey{ + Name: fmt.Sprintf("name%d", i), + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }, + Title: fmt.Sprintf(`test foo%d%sbar`, i, v), + }) + require.NoError(t, err) + } + + for i, v := range search.TermCharacters { + title := fmt.Sprintf(`test foo%d%sbar`, i, v) + query := newQueryByTitle(title) + res, err := index.Search(context.Background(), nil, query, nil) + require.NoError(t, err) + if res.TotalHits != 1 { + fmt.Printf("i: %d, v: %s, title: %s", i, v, title) + } + require.Equal(t, int64(1), res.TotalHits) + + // can search for a title with a term character suffix + title = fmt.Sprintf(`foo%d%s`, i, v) + query = newQueryByTitle(title) + res, err = index.Search(context.Background(), nil, query, nil) + require.NoError(t, err) + if res.TotalHits != 1 { + fmt.Printf("i: %d, v: %s, title: %s", i, v, title) + } + + require.Equal(t, int64(1), res.TotalHits) + } + }) } -func newQuery(query string) *resource.ResourceSearchRequest { +func newTestQuery(query string) *resource.ResourceSearchRequest { return &resource.ResourceSearchRequest{ Options: &resource.ListOptions{ Key: &resource.ResourceKey{ @@ -323,7 +424,21 @@ func newQuery(query string) *resource.ResourceSearchRequest { } } -func newTestDashboardsIndex(t *testing.T) resource.ResourceIndex { +func newQueryByTitle(query string) *resource.ResourceSearchRequest { + return &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Namespace: "default", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + Fields: []*resource.Requirement{{Key: "title", Operator: "=", Values: []string{query}}}, + }, + Limit: 100000, + } +} + +func newTestDashboardsIndex(t TB, threshold int64, size int64, batchSize int64, writer IndexWriter) (resource.ResourceIndex, string) { key := &resource.ResourceKey{ Namespace: "default", Group: "dashboard.grafana.app", @@ -332,17 +447,18 @@ func newTestDashboardsIndex(t *testing.T) resource.ResourceIndex { tmpdir, err := os.MkdirTemp("", "grafana-bleve-test") require.NoError(t, err) - backend, err := NewBleveBackend(BleveOptions{ + backend, err := search.NewBleveBackend(search.BleveOptions{ Root: tmpdir, - FileThreshold: 9999, // use in-memory for tests + FileThreshold: threshold, // use in-memory for tests + BatchSize: int(batchSize), }, tracing.NewNoopTracerService(), featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering), nil) require.NoError(t, err) rv := int64(10) ctx := identity.WithRequester(context.Background(), &user.SignedInUser{Namespace: "ns"}) - info, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) { - return &DashboardDocumentBuilder{ + info, err := search.DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) { + return &search.DashboardDocumentBuilder{ Namespace: namespace, Blob: blob, Stats: make(map[string]map[string]int64), // empty stats @@ -355,10 +471,16 @@ func newTestDashboardsIndex(t *testing.T) resource.ResourceIndex { Namespace: key.Namespace, Group: key.Group, Resource: key.Resource, - }, 2, rv, info.Fields, func(index resource.ResourceIndex) (int64, error) { return 0, nil }) + }, size, rv, info.Fields, writer) require.NoError(t, err) - return index + return index, tmpdir +} + +type IndexWriter func(index resource.ResourceIndex) (int64, error) + +var noop IndexWriter = func(index resource.ResourceIndex) (int64, error) { + return 0, nil } // helper to check which tokens are generated by an analyzer @@ -399,3 +521,15 @@ func debugIndexedTerms(index bleve.Index, field string) { } } } + +// TB is an interface that works for both *testing.T and *testing.B +type TB interface { + Log(args ...interface{}) + Logf(format string, args ...interface{}) + Error(args ...interface{}) + Errorf(format string, args ...interface{}) + Fatal(args ...interface{}) + Fatalf(format string, args ...interface{}) + Helper() + FailNow() +} diff --git a/pkg/storage/unified/search/document_test.go b/pkg/storage/unified/search/document_test.go index c11ada997a6..49c7e0c9e89 100644 --- a/pkg/storage/unified/search/document_test.go +++ b/pkg/storage/unified/search/document_test.go @@ -1,4 +1,4 @@ -package search +package search_test import ( "context" @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/store/kind/dashboard" "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/search" ) func doSnapshotTests(t *testing.T, builder resource.DocumentBuilder, kind string, key *resource.ResourceKey, names []string) { @@ -52,14 +53,14 @@ func TestDashboardDocumentBuilder(t *testing.T) { Resource: "dashboards", } - info, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) { - return &DashboardDocumentBuilder{ + info, err := search.DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) { + return &search.DashboardDocumentBuilder{ Namespace: namespace, Blob: blob, Stats: map[string]map[string]int64{ "aaa": { - DASHBOARD_ERRORS_LAST_1_DAYS: 1, - DASHBOARD_ERRORS_LAST_7_DAYS: 1, + search.DASHBOARD_ERRORS_LAST_1_DAYS: 1, + search.DASHBOARD_ERRORS_LAST_7_DAYS: 1, }, }, DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{ From 08bbd7a536551624d008174324775e11901bac05 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:06:01 -0300 Subject: [PATCH 45/79] fix (unified-storage): always do perm filtering in bleve by default (#102541) * make unifiedStorageSearchPermissionFiltering default true --- .../grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/registry/apis/dashboard/search.go | 1 + pkg/services/featuremgmt/registry.go | 3 ++- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.json | 12 ++++++++---- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index e8b2524d556..40dc24a523d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -814,6 +814,7 @@ export interface FeatureToggles { unifiedStorageSearchSprinkles?: boolean; /** * Enable permission filtering on unified storage search + * @default true */ unifiedStorageSearchPermissionFiltering?: boolean; /** diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 236652d8dd5..86967a9f5d3 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -407,6 +407,7 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) { func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, user identity.Requester) ([]string, error) { if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) { + s.log.Warn("Tried to search for 'sharedwithme' dashboards with ", featuremgmt.FlagUnifiedStorageSearchPermissionFiltering, " disabled") return []string{}, nil } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 0b2003074b9..320342ff7e2 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1399,8 +1399,9 @@ var ( { Name: "unifiedStorageSearchPermissionFiltering", Description: "Enable permission filtering on unified storage search", - Stage: FeatureStageExperimental, + Stage: FeatureStageGeneralAvailability, Owner: grafanaSearchAndStorageSquad, + Expression: "true", HideFromDocs: true, HideFromAdminPage: true, }, diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index e1b5bfda43a..b86059bd2be 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -183,7 +183,7 @@ useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,fal rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false -unifiedStorageSearchPermissionFiltering,experimental,@grafana/search-and-storage,false,false,false +unifiedStorageSearchPermissionFiltering,GA,@grafana/search-and-storage,false,false,false managedDualWriter,experimental,@grafana/search-and-storage,false,false,false pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6de694f18da..3f65b33ffc0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4263,15 +4263,19 @@ { "metadata": { "name": "unifiedStorageSearchPermissionFiltering", - "resourceVersion": "1737489629408", - "creationTimestamp": "2025-01-22T11:38:37Z" + "resourceVersion": "1742564039800", + "creationTimestamp": "2025-01-22T11:38:37Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-03-21 13:33:59.800619 +0000 UTC" + } }, "spec": { "description": "Enable permission filtering on unified storage search", - "stage": "experimental", + "stage": "GA", "codeowner": "@grafana/search-and-storage", "hideFromAdminPage": true, - "hideFromDocs": true + "hideFromDocs": true, + "expression": "true" } }, { From 934fac67a6c014ac8d51f4428c94fa4e1c20c620 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Fri, 21 Mar 2025 16:28:24 +0200 Subject: [PATCH 46/79] Dynamic Dashboards: Improve drag and drop for responsive grid (#102613) --- .../components/PanelChrome/PanelChrome.tsx | 18 +++--- .../layout-manager/LayoutOrchestrator.tsx | 60 +++++++++++++------ .../ResponsiveGridLayout.tsx | 5 +- .../ResponsiveGridLayoutManager.tsx | 20 ++++--- .../ResponsiveGridLayoutRenderer.tsx | 4 +- 5 files changed, 66 insertions(+), 41 deletions(-) diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 3346c110c51..7b534d88b09 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -151,7 +151,7 @@ export function PanelChrome({ const panelContentId = useId(); const panelTitleId = useId().replace(/:/g, '_'); const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId); - const pointerDownEvt = useRef(); + const pointerDownPos = useRef<{ screenX: number; screenY: number }>({ screenX: 0, screenY: 0 }); const hasHeader = !hoverHeader; @@ -203,11 +203,11 @@ export function PanelChrome({ evt.stopPropagation(); const distance = Math.hypot( - pointerDownEvt.current?.screenX ?? 0 - evt.screenX, - pointerDownEvt.current?.screenY ?? 0 - evt.screenY + pointerDownPos.current.screenX - evt.screenX, + pointerDownPos.current.screenY - evt.screenY ); - pointerDownEvt.current = undefined; + pointerDownPos.current = { screenX: 0, screenY: 0 }; // If we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc) if ( @@ -222,7 +222,10 @@ export function PanelChrome({ const onPointerDown = (evt: React.PointerEvent) => { evt.stopPropagation(); - pointerDownEvt.current = evt; + + pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY }; + + onDragStart?.(evt); }; const headerContent = ( @@ -350,11 +353,6 @@ export function PanelChrome({ className={cx(styles.headerContainer, dragClass)} style={headerStyles} data-testid="header-container" - onPointerMove={() => { - if (pointerDownEvt.current) { - onDragStart?.(pointerDownEvt.current); - } - }} onPointerDown={onPointerDown} onMouseEnter={isSelectable ? onHeaderEnter : undefined} onMouseLeave={isSelectable ? onHeaderLeave : undefined} diff --git a/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx b/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx index 2a221189fee..9789f85d005 100644 --- a/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx +++ b/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx @@ -14,6 +14,11 @@ export class LayoutOrchestrator extends SceneObjectBase /** Offset from top-left corner of drag handle. */ public dragOffset = { top: 0, left: 0 }; + /** The drop zone closest to the current mouse position while dragging. */ + public activeDropZone: (DropZone & { layout: SceneObjectRef }) | undefined; + + private _sceneLayouts: SceneLayoutWithDragAndDrop[] = []; + /** Used in `ResponsiveGridLayout`'s `onPointerDown` method */ public onDragStart = (e: PointerEvent, panel: VizPanel) => { const closestLayoutItem = closestOfType(panel, isDashboardLayoutItem); @@ -27,18 +32,39 @@ export class LayoutOrchestrator extends SceneObjectBase return; } + this._sceneLayouts = sceneGraph + .findAllObjects(this.getRoot(), isSceneLayoutWithDragAndDrop) + .filter(isSceneLayoutWithDragAndDrop); + document.body.setPointerCapture(e.pointerId); + const targetRect = e.target.getBoundingClientRect(); this.dragOffset = { top: e.y - targetRect.top, left: e.x - targetRect.left }; - this.setState({ activeLayoutItemRef: closestLayoutItem.getRef() }); + + closestLayoutItem.containerRef.current?.style.setProperty('--x-pos', `${e.x}px`); + closestLayoutItem.containerRef.current?.style.setProperty('--y-pos', `${e.y}px`); + + const state: Partial = { activeLayoutItemRef: closestLayoutItem.getRef() }; + + this._adjustXY({ x: e.x, y: e.y }, closestLayoutItem); + + this.activeDropZone = this.findClosestDropZone({ x: e.clientX, y: e.clientY }); + if (this.activeDropZone) { + state.placeholder = new DropZonePlaceholder({ + top: this.activeDropZone.top, + left: this.activeDropZone.left, + width: this.activeDropZone.right - this.activeDropZone.left, + height: this.activeDropZone.bottom - this.activeDropZone.top, + }); + } + + this.setState(state); + document.addEventListener('pointermove', this.onDrag); document.addEventListener('pointerup', this.onDragEnd); document.body.classList.add('dragging-active'); }; - /** The drop zone closest to the current mouse position while dragging. */ - public activeDropZone: (DropZone & { layout: SceneObjectRef }) | undefined; - /** Called every tick while a panel is actively being dragged */ public onDrag = (e: PointerEvent) => { const layoutItemContainer = this.state.activeLayoutItemRef?.resolve().containerRef.current; @@ -49,9 +75,10 @@ export class LayoutOrchestrator extends SceneObjectBase const cursorPos: Point = { x: e.clientX, y: e.clientY }; - layoutItemContainer.style.setProperty('--x-pos', `${cursorPos.x}px`); - layoutItemContainer.style.setProperty('--y-pos', `${cursorPos.y}px`); + this._adjustXY(cursorPos); + const closestDropZone = this.findClosestDropZone(cursorPos); + if (!dropZonesAreEqual(this.activeDropZone, closestDropZone)) { this.activeDropZone = closestDropZone; if (this.activeDropZone) { @@ -90,15 +117,7 @@ export class LayoutOrchestrator extends SceneObjectBase } this.moveLayoutItem(activeLayoutItem, targetLayout); - this.setState({ - activeLayoutItemRef: undefined, - }); - this.state.placeholder?.setState({ - top: 0, - left: 0, - width: 0, - height: 0, - }); + this.setState({ activeLayoutItemRef: undefined, placeholder: undefined }); this.activeDropZone = undefined; activeLayoutItemContainer?.removeAttribute('style'); }; @@ -116,12 +135,9 @@ export class LayoutOrchestrator extends SceneObjectBase } public findClosestDropZone(p: Point) { - const sceneLayouts = sceneGraph - .findAllObjects(this.getRoot(), isSceneLayoutWithDragAndDrop) - .filter(isSceneLayoutWithDragAndDrop); let closestDropZone: (DropZone & { layout: SceneObjectRef }) | undefined = undefined; let closestDistance = Number.MAX_VALUE; - for (const layout of sceneLayouts) { + for (const layout of this._sceneLayouts) { const curClosestDropZone = layout.closestDropZone(p); if (curClosestDropZone.distanceToPoint < closestDistance) { closestDropZone = { ...curClosestDropZone, layout: layout.getRef() }; @@ -131,6 +147,12 @@ export class LayoutOrchestrator extends SceneObjectBase return closestDropZone; } + + private _adjustXY(p: Point, activeLayoutItem = this.state.activeLayoutItemRef?.resolve()) { + const container = activeLayoutItem?.containerRef.current; + container?.style.setProperty('--x-pos', `${p.x}px`); + container?.style.setProperty('--y-pos', `${p.y}px`); + } } function dropZonesAreEqual(a?: DropZone, b?: DropZone) { diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx index 3c705b34540..8a94c0d92fb 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx @@ -26,6 +26,9 @@ export interface ResponsiveGridLayoutState extends SceneObjectState, ResponsiveG /** True when the items should be lazy loaded */ isLazy?: boolean; + + /** True when the items should be draggable */ + isDraggable?: boolean; } export interface ResponsiveGridLayoutOptions { @@ -87,7 +90,7 @@ export class ResponsiveGridLayout }; public isDraggable(): boolean { - return true; + return this.state.isDraggable ?? false; } public getDragClass(): string { diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index 3cb79c576d4..0885e481c69 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -5,7 +5,12 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { NewObjectAddedToCanvasEvent, ObjectRemovedFromCanvasEvent } from '../../edit-pane/shared'; import { joinCloneKeys } from '../../utils/clone'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; -import { getGridItemKeyForPanelId, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils'; +import { + forceRenderChildren, + getGridItemKeyForPanelId, + getPanelIdForVizPanel, + getVizPanelKeyForPanelId, +} from '../../utils/utils'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -45,14 +50,6 @@ export class ResponsiveGridLayoutManager autoRows: 'minmax(300px, auto)', }; - public constructor(state: ResponsiveGridLayoutManagerState) { - super(state); - - // @ts-ignore - this.state.layout.getDragClassCancel = () => 'drag-cancel'; - this.state.layout.isDraggable = () => true; - } - public addPanel(vizPanel: VizPanel) { const panelId = dashboardSceneGraph.getNextPanelId(this); @@ -131,6 +128,11 @@ export class ResponsiveGridLayoutManager return panels; } + public editModeChanged(isEditing: boolean) { + this.state.layout.setState({ isDraggable: isEditing }); + forceRenderChildren(this.state.layout, true); + } + public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { return this.clone({ layout: this.state.layout.clone({ diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx index 2139900b4c4..a3976fec4dc 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx @@ -59,8 +59,9 @@ export function ResponsiveGridLayoutRenderer({ model }: SceneComponentProps ({ position: 'relative', width: '100%', height: '100%', - overflow: 'hidden', }), dragging: css({ position: 'fixed', From 77c5e0eeb246c20c05033c85b61ca41cec4554e2 Mon Sep 17 00:00:00 2001 From: Beverly Buchanan <131809838+BeverlyJaneJ@users.noreply.github.com> Date: Fri, 21 Mar 2025 10:32:49 -0400 Subject: [PATCH 47/79] Docs: Update RBAC role modification for cloud user (#102412) added content from support ticket --- .../roles-and-permissions/access-control/_index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sources/administration/roles-and-permissions/access-control/_index.md b/docs/sources/administration/roles-and-permissions/access-control/_index.md index 888006f4ac7..a6815849f49 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/_index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/_index.md @@ -169,6 +169,8 @@ To interact with the API and view or modify basic roles permissions, refer to [t You cannot use a service account to modify basic roles via the RBAC API. To update basic roles, you must be a Grafana administrator and use basic authentication with the request. {{% /admonition %}} +For Cloud customers, contact Support to reset roles. + ### Fixed roles Grafana Enterprise includes the ability for you to assign discrete fixed roles to users, teams, and service accounts. This gives you fine-grained control over user permissions than you would have with basic roles alone. These roles are called "fixed" because you cannot change or delete fixed roles. You can also create _custom_ roles of your own; see more information in the [custom roles section](#custom-roles) below. From 1a00801e6aef96c2dfc66e2ef846999ad30b01cb Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 21 Mar 2025 17:45:25 +0300 Subject: [PATCH 48/79] Provisioning: Merge watch support into live (#102618) --- .betterer.results | 5 + packages/grafana-data/src/types/live.ts | 1 + pkg/api/api.go | 1 + pkg/api/dashboard_test.go | 5 +- pkg/apis/provisioning/v0alpha1/jobs.go | 5 +- .../v0alpha1/zz_generated.openapi.go | 9 +- pkg/services/live/features/watch.go | 214 +++++++++++++ pkg/services/live/live.go | 17 +- pkg/services/live/live_test.go | 6 +- .../provisioning.grafana.app-v0alpha1.json | 15 +- .../api/clients/provisioning/endpoints.gen.ts | 8 +- .../utils/createOnCacheEntryAdded.ts | 12 +- public/app/features/apiserver/client.ts | 47 +-- .../app/features/apiserver/discovery.test.ts | 12 + public/app/features/apiserver/discovery.ts | 84 +++++ .../snapshots/discovery-snapshot.json | 295 ++++++++++++++++++ public/app/features/apiserver/types.ts | 12 + .../plugins/panel/live/LiveChannelEditor.tsx | 33 +- 18 files changed, 723 insertions(+), 58 deletions(-) create mode 100644 pkg/services/live/features/watch.go create mode 100644 public/app/features/apiserver/discovery.test.ts create mode 100644 public/app/features/apiserver/discovery.ts create mode 100644 public/app/features/apiserver/snapshots/discovery-snapshot.json diff --git a/.betterer.results b/.betterer.results index f74ac1484c1..75f00fb1529 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6789,6 +6789,11 @@ exports[`better eslint`] = { "public/app/plugins/panel/histogram/utils.ts:5381": [ [0, 0, 0, "\'@grafana/data/src/transformations/transformers/histogram\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], + "public/app/plugins/panel/live/LiveChannelEditor.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "public/app/plugins/panel/live/LivePanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/packages/grafana-data/src/types/live.ts b/packages/grafana-data/src/types/live.ts index 565a6d3fcae..7f4af737f05 100644 --- a/packages/grafana-data/src/types/live.ts +++ b/packages/grafana-data/src/types/live.ts @@ -12,6 +12,7 @@ export enum LiveChannelScope { Plugin = 'plugin', // namespace = plugin name (singleton works for apps too) Grafana = 'grafana', // namespace = feature Stream = 'stream', // namespace = id for the managed data stream + Watch = 'watch', // namespace = k8s group we will watch } /** diff --git a/pkg/api/api.go b/pkg/api/api.go index 3aefe35ad22..0026529cbd8 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -117,6 +117,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/orgs/edit/:id", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) r.Get("/admin/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) r.Get("/admin/provisioning", reqOrgAdmin, hs.Index) + r.Get("/admin/provisioning/*", reqOrgAdmin, hs.Index) if hs.Features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) { r.Get("/admin/migrate-to-cloud", authorize(cloudmigration.MigrationAssistantAccess), hs.Index) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index b715e17481f..549532806bc 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -135,7 +135,10 @@ func newTestLive(t *testing.T, store db.DB) *live.GrafanaLive { nil, &usagestats.UsageStatsMock{T: t}, nil, - features, acimpl.ProvideAccessControl(features), &dashboards.FakeDashboardService{}, annotationstest.NewFakeAnnotationsRepo(), nil) + features, acimpl.ProvideAccessControl(features), + &dashboards.FakeDashboardService{}, + annotationstest.NewFakeAnnotationsRepo(), + nil, nil) require.NoError(t, err) return gLive } diff --git a/pkg/apis/provisioning/v0alpha1/jobs.go b/pkg/apis/provisioning/v0alpha1/jobs.go index d621bf3411f..1e075fd1c26 100644 --- a/pkg/apis/provisioning/v0alpha1/jobs.go +++ b/pkg/apis/provisioning/v0alpha1/jobs.go @@ -104,16 +104,13 @@ type ExportJobOptions struct { Branch string `json:"branch,omitempty"` // Prefix in target file system - Prefix string `json:"prefix,omitempty"` + Path string `json:"path,omitempty"` // Include the identifier in the exported metadata Identifier bool `json:"identifier"` } type MigrateJobOptions struct { - // Target file prefix - Prefix string `json:"prefix,omitempty"` - // Preserve history (if possible) History bool `json:"history,omitempty"` diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 571f26b2b27..6ddc883ebda 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -108,7 +108,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference Format: "", }, }, - "prefix": { + "path": { SchemaProps: spec.SchemaProps{ Description: "Prefix in target file system", Type: []string{"string"}, @@ -818,13 +818,6 @@ func schema_pkg_apis_provisioning_v0alpha1_MigrateJobOptions(ref common.Referenc SchemaProps: spec.SchemaProps{ Type: []string{"object"}, Properties: map[string]spec.Schema{ - "prefix": { - SchemaProps: spec.SchemaProps{ - Description: "Target file prefix", - Type: []string{"string"}, - Format: "", - }, - }, "history": { SchemaProps: spec.SchemaProps{ Description: "Preserve history (if possible)", diff --git a/pkg/services/live/features/watch.go b/pkg/services/live/features/watch.go new file mode 100644 index 00000000000..05a7f875518 --- /dev/null +++ b/pkg/services/live/features/watch.go @@ -0,0 +1,214 @@ +package features + +import ( + "context" + "fmt" + "strings" + "sync" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" + "github.com/grafana/grafana-plugin-sdk-go/live" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/live/model" +) + +// WatchRunner will start a watch task and broadcast results +type WatchRunner struct { + publisher model.ChannelPublisher + configProvider apiserver.RestConfigProvider + + watchingMu sync.Mutex + watching map[string]*watcher +} + +func NewWatchRunner(publisher model.ChannelPublisher, configProvider apiserver.RestConfigProvider) *WatchRunner { + return &WatchRunner{ + publisher: publisher, + configProvider: configProvider, + watching: make(map[string]*watcher), + } +} + +func (b *WatchRunner) GetHandlerForPath(_ string) (model.ChannelHandler, error) { + return b, nil // all dashboards share the same handler +} + +// Valid paths look like: {version}/{resource}[={name}]/{user.uid} +// * v0alpha1/dashboards/u12345 +// * v0alpha1/dashboards=ABCD/u12345 +func (b *WatchRunner) OnSubscribe(ctx context.Context, u identity.Requester, e model.SubscribeEvent) (model.SubscribeReply, backend.SubscribeStreamStatus, error) { + // To make sure we do not share resources across users, in clude the UID in the path + userID := u.GetIdentifier() + if userID == "" { + return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("missing user identity") + } + if !strings.HasSuffix(e.Path, userID) { + return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("path must end with user uid (%s)", userID) + } + + // While testing with provisioning repositories, we will limit this to admin only + if !u.HasRole(identity.RoleAdmin) { + return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("only admin users for now") + } + + b.watchingMu.Lock() + defer b.watchingMu.Unlock() + + current, ok := b.watching[e.Channel] + if ok && !current.done { + return model.SubscribeReply{ + JoinLeave: false, + Presence: false, + Recover: false, + }, backend.SubscribeStreamStatusOK, nil + } + + // Try to start a watcher for this request + gvr, name, err := parseWatchRequest(e.Channel, userID) + if err != nil { + return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err + } + + // Test this with only provisiong support -- then we can evaluate a broader rollout + if gvr.Group != provisioning.GROUP { + return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, + fmt.Errorf("watching provisioned resources is OK allowed (for now)") + } + + requester := types.WithAuthInfo(context.Background(), u) + cfg, err := b.configProvider.GetRestConfig(requester) + if err != nil { + return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err + } + uclient, err := dynamic.NewForConfig(cfg) + if err != nil { + return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err + } + client := uclient.Resource(gvr).Namespace(u.GetNamespace()) + + opts := v1.ListOptions{} + if len(name) > 1 { + opts.FieldSelector = "metadata.name=" + name + } + watch, err := client.Watch(requester, opts) + if err != nil { + return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err + } + + current = &watcher{ + orgId: u.GetOrgID(), + channel: e.Channel, + publisher: b.publisher, + watch: watch, + } + + b.watching[e.Channel] = current + go current.run(ctx) + + return model.SubscribeReply{ + JoinLeave: false, // need unsubscribe envents + Presence: false, + Recover: false, + }, backend.SubscribeStreamStatusOK, nil +} + +func parseWatchRequest(channel string, user string) (gvr schema.GroupVersionResource, name string, err error) { + addr, err := live.ParseChannel(channel) + if err != nil { + return gvr, "", err + } + + parts := strings.Split(addr.Path, "/") + if len(parts) != 3 { + return gvr, "", fmt.Errorf("expecting path: {version}/{resource}={name}/{user}") + } + if parts[2] != user { + return gvr, "", fmt.Errorf("expecting user suffix: %s", user) + } + + resource := strings.Split(parts[1], "=") + gvr = schema.GroupVersionResource{ + Group: addr.Namespace, + Version: parts[0], + Resource: resource[0], + } + if len(resource) > 1 { + name = resource[1] + } + return gvr, name, nil +} + +// OnPublish is called when a client wants to broadcast on the websocket +func (b *WatchRunner) OnPublish(_ context.Context, u identity.Requester, e model.PublishEvent) (model.PublishReply, backend.PublishStreamStatus, error) { + return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("watch does not support publish") +} + +type watcher struct { + orgId int64 + channel string + publisher model.ChannelPublisher + done bool + watch watch.Interface +} + +func (b *watcher) run(ctx context.Context) { + logger := logging.FromContext(ctx).With("channel", b.channel) + + ch := b.watch.ResultChan() + for { + select { + // This is sent when there are no longer any subscriptions + case <-ctx.Done(): + logger.Info("context done", "channel", b.channel) + b.watch.Stop() + b.done = true + return + + // Each watch event + case event, ok := <-ch: + if !ok { + logger.Info("watch stream broken", "channel", b.channel) + b.watch.Stop() + b.done = true // will force reconnect from the frontend + return + } + + cfg := jsoniter.ConfigCompatibleWithStandardLibrary + stream := cfg.BorrowStream(nil) + defer cfg.ReturnStream(stream) + + // regular json.Marshal() uses upper case + stream.WriteObjectStart() + stream.WriteObjectField("type") + stream.WriteString(string(event.Type)) + stream.WriteMore() + stream.WriteObjectField("object") + stream.WriteVal(event.Object) + stream.WriteObjectEnd() + + buf := stream.Buffer() + data := make([]byte, len(buf)) + copy(data, buf) + + err := b.publisher(b.orgId, b.channel, data) + if err != nil { + logger.Error("publish error", "channel", b.channel, "err", err) + b.watch.Stop() + b.done = true // will force reconnect from the frontend + continue + } + } + } +} diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 64efaaa18b6..eb854f99d9c 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -36,6 +36,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/apiserver" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" @@ -79,7 +80,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r dataSourceCache datasources.CacheService, sqlStore db.DB, secretsService secrets.Service, usageStatsService usagestats.Service, queryDataService query.Service, toggles featuremgmt.FeatureToggles, accessControl accesscontrol.AccessControl, dashboardService dashboards.DashboardService, annotationsRepo annotations.Repository, - orgService org.Service) (*GrafanaLive, error) { + orgService org.Service, configProvider apiserver.RestConfigProvider) (*GrafanaLive, error) { g := &GrafanaLive{ Cfg: cfg, Features: toggles, @@ -191,6 +192,11 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r g.GrafanaScope.Features["dashboard"] = dash g.GrafanaScope.Features["broadcast"] = features.NewBroadcastRunner(g.storage) + // Testing watch with just the provisioning support -- this will be removed when it is well validated + if toggles.IsEnabledGlobally(featuremgmt.FlagProvisioning) { + g.GrafanaScope.Features["watch"] = features.NewWatchRunner(g.Publish, configProvider) + } + g.surveyCaller = survey.NewCaller(managedStreamRunner, node) err = g.surveyCaller.SetupHandlers() if err != nil { @@ -889,6 +895,8 @@ func (g *GrafanaLive) GetChannelHandlerFactory(ctx context.Context, user identit switch scope { case live.ScopeGrafana: return g.handleGrafanaScope(user, namespace) + case "watch": // TODO: live.ScopeWatch: update 275 https://github.com/grafana/grafana-plugin-sdk-go/releases + return g.handleWatchScope() case live.ScopePlugin: return g.handlePluginScope(ctx, user, namespace) case live.ScopeDatasource: @@ -907,6 +915,13 @@ func (g *GrafanaLive) handleGrafanaScope(_ identity.Requester, namespace string) return nil, fmt.Errorf("unknown feature: %q", namespace) } +func (g *GrafanaLive) handleWatchScope() (model.ChannelHandlerFactory, error) { + if p, ok := g.GrafanaScope.Features["watch"]; ok { + return p, nil + } + return nil, fmt.Errorf("watch not registered") +} + func (g *GrafanaLive) handlePluginScope(ctx context.Context, _ identity.Requester, namespace string) (model.ChannelHandlerFactory, error) { streamHandler, err := g.getStreamPlugin(ctx, namespace) if err != nil { diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go index 447910e1b84..c002ec7c705 100644 --- a/pkg/services/live/live_test.go +++ b/pkg/services/live/live_test.go @@ -36,7 +36,11 @@ func Test_provideLiveService_RedisUnavailable(t *testing.T) { nil, &usagestats.UsageStatsMock{T: t}, nil, - featuremgmt.WithFeatures(), acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), &dashboards.FakeDashboardService{}, annotationstest.NewFakeAnnotationsRepo(), nil) + featuremgmt.WithFeatures(), + acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), + &dashboards.FakeDashboardService{}, + annotationstest.NewFakeAnnotationsRepo(), + nil, nil) // Proceeds without live HA if redis is unavaialble require.NoError(t, 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 d74ca23d0be..5121b7861eb 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -1121,7 +1121,7 @@ "type": "boolean", "default": false }, - "prefix": { + "path": { "description": "Prefix in target file system", "type": "string" } @@ -1130,7 +1130,7 @@ "example": { "folder": "grafan-folder-ref", "branch": "target-branch", - "prefix": "prefix/in/repo/tree", + "path": "path/in/tree", "identifier": false } } @@ -1780,15 +1780,10 @@ "description": "Include the identifier in the exported metadata", "type": "boolean", "default": false - }, - "prefix": { - "description": "Target file prefix", - "type": "string" } } }, "example": { - "prefix": "prefix/in/repo/tree", "history": true, "identifier": false } @@ -2567,7 +2562,7 @@ "type": "boolean", "default": false }, - "prefix": { + "path": { "description": "Prefix in target file system", "type": "string" } @@ -3035,10 +3030,6 @@ "description": "Include the identifier in the exported metadata", "type": "boolean", "default": false - }, - "prefix": { - "description": "Target file prefix", - "type": "string" } } }, diff --git a/public/app/api/clients/provisioning/endpoints.gen.ts b/public/app/api/clients/provisioning/endpoints.gen.ts index 8f22200d13f..a7baef15950 100644 --- a/public/app/api/clients/provisioning/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/endpoints.gen.ts @@ -497,7 +497,7 @@ export type CreateRepositoryExportApiArg = { /** Include the identifier in the exported metadata */ identifier: boolean; /** Prefix in target file system */ - prefix?: string; + path?: string; }; }; export type GetRepositoryFilesApiResponse = /** status 200 OK */ { @@ -587,8 +587,6 @@ export type CreateRepositoryMigrateApiArg = { history?: boolean; /** Include the identifier in the exported metadata */ identifier: boolean; - /** Target file prefix */ - prefix?: string; }; }; export type GetRepositoryRenderWithPathApiResponse = unknown; @@ -748,8 +746,6 @@ export type MigrateJobOptions = { history?: boolean; /** Include the identifier in the exported metadata */ identifier: boolean; - /** Target file prefix */ - prefix?: string; }; export type PullRequestJobOptions = { hash?: string; @@ -772,7 +768,7 @@ export type ExportJobOptions = { /** Include the identifier in the exported metadata */ identifier: boolean; /** Prefix in target file system */ - prefix?: string; + path?: string; }; export type JobSpec = { /** Possible enum values: diff --git a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts index 724eb79193d..c3c4f38b8e1 100644 --- a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts +++ b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts @@ -48,17 +48,17 @@ export function createOnCacheEntryAdded< } const existingIndex = draft.items.findIndex((item) => item.metadata?.name === event.object.metadata.name); - if (event.type === 'ADDED') { - // Add the new item + if (event.type === 'ADDED' && existingIndex === -1) { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions draft.items.push(event.object as unknown as T); - } else if (event.type === 'MODIFIED' && existingIndex !== -1) { - // Update the existing item if it exists - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - draft.items[existingIndex] = event.object as unknown as T; } else if (event.type === 'DELETED' && existingIndex !== -1) { // Remove the item if it exists draft.items.splice(existingIndex, 1); + } else if (existingIndex !== -1) { + // Could be ADDED or MODIFIED + // Update the existing item if it exists + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + draft.items[existingIndex] = event.object as unknown as T; } }); }); diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index cb53c57aa65..2af5503674f 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -1,6 +1,7 @@ import { Observable, from, retry, catchError, filter, map, mergeMap } from 'rxjs'; -import { BackendSrvRequest, config, getBackendSrv } from '@grafana/runtime'; +import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data'; +import { config, getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; import { getAPINamespace } from '../../api/utils'; @@ -19,20 +20,16 @@ import { K8sAPIGroupList, AnnoKeySavedFromUI, ResourceEvent, + GroupVersionResource, } from './types'; -export interface GroupVersionResource { - group: string; - version: string; - resource: string; -} - export class ScopedResourceClient implements ResourceClient { readonly url: string; + readonly gvr: GroupVersionResource; constructor(gvr: GroupVersionResource, namespaced = true) { const ns = namespaced ? `namespaces/${getAPINamespace()}/` : ''; - + this.gvr = gvr; this.url = `/apis/${gvr.group}/${gvr.version}/${ns}${gvr.resource}`; } @@ -40,26 +37,40 @@ export class ScopedResourceClient implements return getBackendSrv().get>(`${this.url}/${name}`); } - public watch( - params?: WatchOptions, - config?: Pick - ): Observable> { - const decoder = new TextDecoder(); - const { name, ...rest } = params ?? {}; // name needs to be added to fieldSelector + public watch(params?: WatchOptions): Observable> { const requestParams = { - ...rest, watch: true, labelSelector: this.parseListOptionsSelector(params?.labelSelector), fieldSelector: this.parseListOptionsSelector(params?.fieldSelector), }; - if (name) { + if (params?.name) { requestParams.fieldSelector = `metadata.name=${name}`; } + + // For now, watch over live only supports provisioning + if (this.gvr.group === 'provisioning.grafana.app') { + let query = ''; + if (requestParams.fieldSelector?.startsWith('metadata.name=')) { + query = requestParams.fieldSelector.substring('metadata.name'.length); + } + return getGrafanaLiveSrv() + .getStream>({ + scope: LiveChannelScope.Watch, + namespace: this.gvr.group, + path: `${this.gvr.version}/${this.gvr.resource}${query}/${config.bootData.user.uid}`, + }) + .pipe( + filter((event) => isLiveChannelMessageEvent(event)), + map((event) => event.message) + ); + } + + const decoder = new TextDecoder(); return getBackendSrv() .chunked({ url: this.url, params: requestParams, - ...config, + method: 'GET', }) .pipe( filter((response) => response.ok && response.data instanceof Uint8Array), @@ -73,7 +84,7 @@ export class ScopedResourceClient implements try { return JSON.parse(line); } catch (e) { - console.warn('Invalid JSON in watch stream:', e); + console.warn('Invalid JSON in watch stream:', e, line); return null; } }), diff --git a/public/app/features/apiserver/discovery.test.ts b/public/app/features/apiserver/discovery.test.ts new file mode 100644 index 00000000000..5b784197eea --- /dev/null +++ b/public/app/features/apiserver/discovery.test.ts @@ -0,0 +1,12 @@ +import { discoveryResources } from './discovery'; + +const discoverySnapshot = require('./snapshots/discovery-snapshot.json'); + +describe('simple typescript tests', () => { + it('simple', async () => { + const watchable = discoveryResources(discoverySnapshot) + .filter((v) => v.verbs.includes('watch')) + .map((v) => v.resource); + expect(watchable).toEqual(['user-storage', 'dashboards', 'dashboards', 'dashboards']); + }); +}); diff --git a/public/app/features/apiserver/discovery.ts b/public/app/features/apiserver/discovery.ts new file mode 100644 index 00000000000..ece21681d48 --- /dev/null +++ b/public/app/features/apiserver/discovery.ts @@ -0,0 +1,84 @@ +import { lastValueFrom, map } from 'rxjs'; + +import { FetchResponse, getBackendSrv } from '@grafana/runtime'; + +import { GroupVersionKind, ListMeta } from './types'; + +export type GroupDiscoveryResource = { + resource: string; + responseKind: GroupVersionKind; + scope: 'Namespaced' | 'Cluster'; + singularResource: string; + verbs: string[]; + subresources?: GroupDiscoverySubresource[]; +}; + +export type GroupDiscoverySubresource = { + subresource: string; + responseKind: GroupVersionKind; + verbs: string[]; +}; + +export type GroupDiscoveryVersion = { + version: string; + freshness: 'Current' | string; + resources: GroupDiscoveryResource[]; +}; + +export type GroupDiscoveryItem = { + metadata: { + name: string; + }; + versions: GroupDiscoveryVersion[]; +}; + +export type APIGroupDiscoveryList = { + metadata: ListMeta; + items: GroupDiscoveryItem[]; +}; + +export async function getAPIGroupDiscoveryList(): Promise { + return await lastValueFrom( + getBackendSrv() + .fetch({ + method: 'GET', + url: '/apis', + headers: { + Accept: + 'application/json;g=apidiscovery.k8s.io;v=v2;as=APIGroupDiscoveryList,application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json', + }, + }) + .pipe( + map((response: FetchResponse) => { + // Fill in the group+version before returning + for (let api of response.data.items) { + for (let version of api.versions) { + for (let resource of version.resources) { + resource.responseKind.group = api.metadata.name; + resource.responseKind.version = version.version; + if (resource.subresources) { + for (let sub of resource.subresources) { + sub.responseKind.group = api.metadata.name; + sub.responseKind.version = version.version; + } + } + } + } + } + return response.data; + }) + ) + ); +} + +export function discoveryResources(apis: APIGroupDiscoveryList): GroupDiscoveryResource[] { + const resources: GroupDiscoveryResource[] = []; + for (let api of apis.items) { + for (let version of api.versions) { + for (let resource of version.resources) { + resources.push(resource); + } + } + } + return resources; +} diff --git a/public/app/features/apiserver/snapshots/discovery-snapshot.json b/public/app/features/apiserver/snapshots/discovery-snapshot.json new file mode 100644 index 00000000000..36fc7f05aa3 --- /dev/null +++ b/public/app/features/apiserver/snapshots/discovery-snapshot.json @@ -0,0 +1,295 @@ +{ + "kind": "APIGroupDiscoveryList", + "apiVersion": "apidiscovery.k8s.io/v2", + "metadata": {}, + "items": [ + { + "metadata": { "name": "userstorage.grafana.app", "creationTimestamp": null }, + "versions": [ + { + "version": "v0alpha1", + "resources": [ + { + "resource": "user-storage", + "responseKind": { "group": "", "version": "", "kind": "UserStorage" }, + "scope": "Namespaced", + "singularResource": "user-storage", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"] + } + ], + "freshness": "Current" + } + ] + }, + { + "metadata": { "name": "notifications.alerting.grafana.app", "creationTimestamp": null }, + "versions": [ + { + "version": "v0alpha1", + "resources": [ + { + "resource": "receivers", + "responseKind": { "group": "", "version": "", "kind": "Receiver" }, + "scope": "Namespaced", + "singularResource": "receiver", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"] + }, + { + "resource": "routingtrees", + "responseKind": { "group": "", "version": "", "kind": "RoutingTree" }, + "scope": "Namespaced", + "singularResource": "routingtree", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"] + }, + { + "resource": "templategroups", + "responseKind": { "group": "", "version": "", "kind": "TemplateGroup" }, + "scope": "Namespaced", + "singularResource": "templategroup", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"] + }, + { + "resource": "timeintervals", + "responseKind": { "group": "", "version": "", "kind": "TimeInterval" }, + "scope": "Namespaced", + "singularResource": "timeinterval", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"] + } + ], + "freshness": "Current" + } + ] + }, + { + "metadata": { "name": "iam.grafana.app", "creationTimestamp": null }, + "versions": [ + { + "version": "v0alpha1", + "resources": [ + { + "resource": "serviceaccounts", + "responseKind": { "group": "", "version": "", "kind": "ServiceAccount" }, + "scope": "Namespaced", + "singularResource": "serviceaccount", + "verbs": ["get", "list"], + "subresources": [ + { + "subresource": "tokens", + "responseKind": { "group": "", "version": "", "kind": "UserTeamList" }, + "verbs": ["get"] + } + ] + }, + { + "resource": "ssosettings", + "responseKind": { "group": "", "version": "", "kind": "SSOSetting" }, + "scope": "Namespaced", + "singularResource": "ssosetting", + "verbs": ["delete", "get", "list", "patch", "update"] + }, + { + "resource": "teambindings", + "responseKind": { "group": "", "version": "", "kind": "TeamBinding" }, + "scope": "Namespaced", + "singularResource": "teambinding", + "verbs": ["get", "list"] + }, + { + "resource": "teams", + "responseKind": { "group": "", "version": "", "kind": "Team" }, + "scope": "Namespaced", + "singularResource": "team", + "verbs": ["get", "list"], + "subresources": [ + { + "subresource": "members", + "responseKind": { "group": "", "version": "", "kind": "TeamMemberList" }, + "verbs": ["get"] + } + ] + }, + { + "resource": "users", + "responseKind": { "group": "", "version": "", "kind": "User" }, + "scope": "Namespaced", + "singularResource": "user", + "verbs": ["get", "list"], + "subresources": [ + { + "subresource": "teams", + "responseKind": { "group": "", "version": "", "kind": "UserTeamList" }, + "verbs": ["get"] + } + ] + } + ], + "freshness": "Current" + } + ] + }, + { + "metadata": { "name": "folder.grafana.app", "creationTimestamp": null }, + "versions": [ + { + "version": "v0alpha1", + "resources": [ + { + "resource": "folders", + "responseKind": { "group": "", "version": "", "kind": "Folder" }, + "scope": "Namespaced", + "singularResource": "folder", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"], + "subresources": [ + { + "subresource": "access", + "responseKind": { "group": "", "version": "", "kind": "FolderAccessInfo" }, + "verbs": ["get"] + }, + { + "subresource": "counts", + "responseKind": { "group": "", "version": "", "kind": "DescendantCounts" }, + "verbs": ["get"] + }, + { + "subresource": "parents", + "responseKind": { "group": "", "version": "", "kind": "FolderInfoList" }, + "verbs": ["get"] + } + ] + } + ], + "freshness": "Current" + } + ] + }, + { + "metadata": { "name": "featuretoggle.grafana.app", "creationTimestamp": null }, + "versions": [ + { + "version": "v0alpha1", + "resources": [ + { + "resource": "features", + "responseKind": { "group": "", "version": "", "kind": "Feature" }, + "scope": "Cluster", + "singularResource": "feature", + "verbs": ["get", "list"] + }, + { + "resource": "featuretoggles", + "responseKind": { "group": "", "version": "", "kind": "FeatureToggles" }, + "scope": "Namespaced", + "singularResource": "featuretoggle", + "verbs": ["get", "list"] + } + ], + "freshness": "Current" + } + ] + }, + { + "metadata": { "name": "dashboard.grafana.app", "creationTimestamp": null }, + "versions": [ + { + "version": "v0alpha1", + "resources": [ + { + "resource": "dashboards", + "responseKind": { "group": "", "version": "", "kind": "Dashboard" }, + "scope": "Namespaced", + "singularResource": "dashboard", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"], + "subresources": [ + { + "subresource": "dto", + "responseKind": { "group": "", "version": "", "kind": "DashboardWithAccessInfo" }, + "verbs": ["get"] + } + ] + }, + { + "resource": "librarypanels", + "responseKind": { "group": "", "version": "", "kind": "LibraryPanel" }, + "scope": "Namespaced", + "singularResource": "librarypanel", + "verbs": ["get", "list"] + } + ], + "freshness": "Current" + }, + { + "version": "v1alpha1", + "resources": [ + { + "resource": "dashboards", + "responseKind": { "group": "", "version": "", "kind": "Dashboard" }, + "scope": "Namespaced", + "singularResource": "dashboard", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"], + "subresources": [ + { + "subresource": "dto", + "responseKind": { "group": "", "version": "", "kind": "DashboardWithAccessInfo" }, + "verbs": ["get"] + } + ] + }, + { + "resource": "librarypanels", + "responseKind": { "group": "", "version": "", "kind": "LibraryPanel" }, + "scope": "Namespaced", + "singularResource": "librarypanel", + "verbs": ["get", "list"] + } + ], + "freshness": "Current" + }, + { + "version": "v2alpha1", + "resources": [ + { + "resource": "dashboards", + "responseKind": { "group": "", "version": "", "kind": "Dashboard" }, + "scope": "Namespaced", + "singularResource": "dashboard", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"], + "subresources": [ + { + "subresource": "dto", + "responseKind": { "group": "", "version": "", "kind": "DashboardWithAccessInfo" }, + "verbs": ["get"] + } + ] + }, + { + "resource": "librarypanels", + "responseKind": { "group": "", "version": "", "kind": "LibraryPanel" }, + "scope": "Namespaced", + "singularResource": "librarypanel", + "verbs": ["get", "list"] + } + ], + "freshness": "Current" + } + ] + }, + { + "metadata": { "name": "playlist.grafana.app", "creationTimestamp": null }, + "versions": [ + { + "version": "v0alpha1", + "resources": [ + { + "resource": "playlists", + "responseKind": { "group": "", "version": "", "kind": "Playlist" }, + "scope": "Namespaced", + "singularResource": "playlist", + "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"] + } + ], + "freshness": "Current" + } + ] + } + ] +} diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts index 06199f6d677..631f56f505c 100644 --- a/public/app/features/apiserver/types.ts +++ b/public/app/features/apiserver/types.ts @@ -111,6 +111,18 @@ type GrafanaLabels = { [DeprecatedInternalId]?: string; }; +export interface GroupVersionResource { + group: string; + version: string; + resource: string; +} + +export interface GroupVersionKind { + group: string; + version: string; + kind: string; +} + export interface Resource extends TypeMeta { metadata: ObjectMeta; spec: T; diff --git a/public/app/plugins/panel/live/LiveChannelEditor.tsx b/public/app/plugins/panel/live/LiveChannelEditor.tsx index d5ba8f45fe1..3276992eb37 100644 --- a/public/app/plugins/panel/live/LiveChannelEditor.tsx +++ b/public/app/plugins/panel/live/LiveChannelEditor.tsx @@ -9,8 +9,9 @@ import { GrafanaTheme2, parseLiveChannelAddress, } from '@grafana/data'; -import { Select, Alert, Label, stylesFactory } from '@grafana/ui'; +import { Select, Alert, Label, stylesFactory, Combobox } from '@grafana/ui'; import { config } from 'app/core/config'; +import { discoveryResources, getAPIGroupDiscoveryList, GroupDiscoveryResource } from 'app/features/apiserver/discovery'; import { getManagedChannelInfo } from 'app/features/live/info'; import { LivePanelOptions } from './types'; @@ -22,6 +23,7 @@ const scopes: Array> = [ { label: 'Data Sources', value: LiveChannelScope.DataSource, description: 'Data sources with live support' }, { label: 'Plugins', value: LiveChannelScope.Plugin, description: 'Plugins with live support' }, { label: 'Stream', value: LiveChannelScope.Stream, description: 'data streams (eg, influx style)' }, + { label: 'Watch', value: LiveChannelScope.Watch, description: 'Watch k8s style resources' }, ]; export function LiveChannelEditor(props: Props) { @@ -93,6 +95,16 @@ export function LiveChannelEditor(props: Props) { }); }; + const getWatchableResources = async (v: string) => { + const apis = await getAPIGroupDiscoveryList(); + return discoveryResources(apis) + .filter((v) => v.verbs.includes('watch')) + .map((r) => ({ + value: `${r.responseKind.group}/${r.responseKind.version}/${r.resource}`, // must be string | number + resource: r, + })); + }; + const { scope, namespace, path } = props.value; const style = getStyles(config.theme2); @@ -109,6 +121,25 @@ export function LiveChannelEditor(props: Props) { { - updateFilter({ ...f, scope: v?.value, tag: '' }); - }} - options={scopeOptions} - placeholder="Select scope" - value={f.scope} - /> -