From fe3e2bf9cbc6eb624b372436f30553b3a9fea795 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Thu, 27 Nov 2025 12:45:25 +0200 Subject: [PATCH 01/31] Provisioning: Unify resource and file list pages (#114508) * Provisioning: Unify resources and files view * Use interactive table * Add tests * Show status * Omit root * Fix status * Fix link * Tab spacing * Cleanup * Move funciton outside * Add source link * Hide source link for unsynced files * Show folders sync status * refactor * Fix sync folder logic * refactor * fix unsynced files type * Show external source link * tweaks * SHow pending for unsynced files --- .../provisioning/File/FilesView.test.tsx | 166 ---- .../features/provisioning/File/FilesView.tsx | 99 --- .../Repository/RepositoryResources.tsx | 146 ---- .../Repository/RepositoryStatusPage.tsx | 20 +- .../Repository/ResourceTreeView.tsx | 213 ++++++ public/app/features/provisioning/types.ts | 23 +- public/app/features/provisioning/utils/git.ts | 129 +++- .../provisioning/utils/treeUtils.test.ts | 715 ++++++++++++++++++ .../features/provisioning/utils/treeUtils.ts | 229 ++++++ public/locales/en-US/grafana.json | 30 +- 10 files changed, 1290 insertions(+), 480 deletions(-) delete mode 100644 public/app/features/provisioning/File/FilesView.test.tsx delete mode 100644 public/app/features/provisioning/File/FilesView.tsx delete mode 100644 public/app/features/provisioning/Repository/RepositoryResources.tsx create mode 100644 public/app/features/provisioning/Repository/ResourceTreeView.tsx create mode 100644 public/app/features/provisioning/utils/treeUtils.test.ts create mode 100644 public/app/features/provisioning/utils/treeUtils.ts diff --git a/public/app/features/provisioning/File/FilesView.test.tsx b/public/app/features/provisioning/File/FilesView.test.tsx deleted file mode 100644 index ed29db649ae..00000000000 --- a/public/app/features/provisioning/File/FilesView.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { render, screen, waitFor } from 'test/test-utils'; - -import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning/v0alpha1'; - -import { FilesView } from './FilesView'; - -jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ - useGetRepositoryFilesQuery: jest.fn(), -})); - -const mockUseGetRepositoryFilesQuery = jest.mocked(useGetRepositoryFilesQuery); -type RepositoryFilesQueryResult = ReturnType; - -const baseQueryResult = (): RepositoryFilesQueryResult => - ({ - currentData: undefined, - data: { items: [] }, - endpointName: 'getRepositoryFiles', - error: undefined, - fulfilledTimeStamp: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - originalArgs: { name: '' }, - refetch: jest.fn(), - requestId: 'test-request', - startedTimeStamp: 0, - status: 'uninitialized', - subscriptionOptions: undefined, - unsubscribe: jest.fn(), - }) satisfies RepositoryFilesQueryResult; - -const mockRepositoryFilesQuery = (overrides: Partial = {}) => { - mockUseGetRepositoryFilesQuery.mockReturnValue({ - ...baseQueryResult(), - ...overrides, - }); -}; - -const defaultRepository: Repository = { - metadata: { name: 'test-repo' }, - spec: { - title: 'Test repository', - type: 'github', - workflows: ['write'], - sync: { enabled: true, target: 'folder' }, - github: { branch: 'main' }, - }, -}; - -const localRepository: Repository = { - metadata: { name: 'local-repo' }, - spec: { - title: 'Local repository', - type: 'local', - workflows: [], - sync: { enabled: true, target: 'folder' }, - local: {}, - }, -}; - -const renderComponent = (repo: Repository = defaultRepository) => { - return render(); -}; - -describe('FilesView', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders spinner while loading', () => { - mockRepositoryFilesQuery({ isLoading: true, status: 'pending', data: undefined }); - - renderComponent(); - - expect(screen.getByTestId('Spinner')).toBeInTheDocument(); - }); - - it('renders file rows with view and history links when data is available', () => { - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }], - }, - }); - - renderComponent(); - - const viewLink = screen.getByRole('link', { name: 'View' }); - expect(viewLink).toHaveAttribute('href', '/admin/provisioning/test-repo/file/dashboards/example.json'); - - const historyLink = screen.getByRole('link', { name: 'History' }); - expect(historyLink).toHaveAttribute( - 'href', - '/admin/provisioning/test-repo/history/dashboards/example.json?repo_type=github' - ); - }); - - it('filters files using search input', async () => { - const mockItems = [ - { path: 'dashboards/example.json', hash: 'abc', size: '10' }, - { path: 'dashboards/other.yaml', hash: 'def', size: '20' }, - ]; - - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: mockItems, - }, - }); - - const { user } = renderComponent(); - - expect(screen.getAllByRole('row')).toHaveLength( - // +1 for the header row - mockItems.length + 1 - ); - - const input = screen.getByPlaceholderText('Search'); - await user.clear(input); - await user.type(input, 'other'); - - await waitFor(() => - expect(screen.getAllByRole('row')).toHaveLength( - // +1 for the header row - 2 - ) - ); - expect(screen.getByText('dashboards/other.yaml')).toBeInTheDocument(); - }); - - it('hides history link when repository type is not supported', () => { - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }], - }, - }); - - renderComponent(localRepository); - - expect(screen.getByRole('link', { name: 'View' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); - }); - - it('renders plain text and hides actions for .keep files', () => { - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: [{ path: 'dashboards/.keep', hash: 'abc', size: '0' }], - }, - }); - - renderComponent(); - - expect(screen.getByText('dashboards/.keep')).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'dashboards/.keep' })).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'View' })).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); - }); -}); diff --git a/public/app/features/provisioning/File/FilesView.tsx b/public/app/features/provisioning/File/FilesView.tsx deleted file mode 100644 index 97ac77b2fc4..00000000000 --- a/public/app/features/provisioning/File/FilesView.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState } from 'react'; - -import { Trans, t } from '@grafana/i18n'; -import { CellProps, Column, FilterInput, InteractiveTable, LinkButton, Spinner, Stack } from '@grafana/ui'; -import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning/v0alpha1'; - -import { PROVISIONING_URL } from '../constants'; -import { FileDetails } from '../types'; - -import { isFileHistorySupported } from './utils'; - -interface FilesViewProps { - repo: Repository; -} - -type FileCell = CellProps; - -export function FilesView({ repo }: FilesViewProps) { - const name = repo.metadata?.name ?? ''; - const query = useGetRepositoryFilesQuery({ name }); - const [searchQuery, setSearchQuery] = useState(''); - const data = [...(query.data?.items ?? [])].filter((file) => - file.path.toLowerCase().includes(searchQuery.toLowerCase()) - ); - const showHistoryBtn = isFileHistorySupported(repo.spec?.type); - - const columns: Array> = [ - { - id: 'path', - header: 'Path', - sortType: 'string', - cell: ({ row: { original } }: FileCell<'path'>) => { - const { path } = original; - const isDotKeepFile = getIsDotKeepFile(path); - if (isDotKeepFile) { - return path; - } - return {path}; - }, - }, - { - id: 'hash', - header: 'Hash', - sortType: 'string', - }, - { - id: 'actions', - header: '', - cell: ({ row: { original } }: FileCell<'path'>) => { - const { path } = original; - const isDotKeepFile = getIsDotKeepFile(path); - if (isDotKeepFile) { - return null; - } - return ( - - {(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && ( - - View - - )} - {showHistoryBtn && ( - - History - - )} - - ); - }, - }, - ]; - - if (query.isLoading) { - return ( - - - - ); - } - - return ( - - - - - String(f.path)} /> - - ); -} - -function getIsDotKeepFile(path: string): boolean { - // e.g. 'dashboards/.keep' → true, 'dashboards/example.keep.json' → false - return path.split('/').pop() === '.keep'; -} diff --git a/public/app/features/provisioning/Repository/RepositoryResources.tsx b/public/app/features/provisioning/Repository/RepositoryResources.tsx deleted file mode 100644 index fdffe1cea74..00000000000 --- a/public/app/features/provisioning/Repository/RepositoryResources.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { Trans, t } from '@grafana/i18n'; -import { CellProps, Column, FilterInput, InteractiveTable, Link, LinkButton, Spinner, Stack } from '@grafana/ui'; -import { Repository, ResourceListItem, useGetRepositoryResourcesQuery } from 'app/api/clients/provisioning/v0alpha1'; - -import { isFileHistorySupported } from '../File/utils'; -import { PROVISIONING_URL } from '../constants'; - -interface RepoProps { - repo: Repository; -} - -type ResourceCell = CellProps< - ResourceListItem, - ResourceListItem[T] ->; - -export function RepositoryResources({ repo }: RepoProps) { - const name = repo.metadata?.name ?? ''; - const query = useGetRepositoryResourcesQuery({ name }); - const [searchQuery, setSearchQuery] = useState(''); - const data = (query.data?.items ?? []).filter((Resource) => - Resource.path.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - // hide history button when repo type is pure git as it won't be implemented. - const historySupported = isFileHistorySupported(repo.spec?.type); - - const columns: Array> = useMemo( - () => [ - { - id: 'title', - header: 'Title', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'title'>) => { - const { resource, name, title } = original; - if (resource === 'dashboards') { - return {title}; - } - if (resource === 'folders') { - return {title}; - } - return {title}; - }, - }, - { - id: 'resource', - header: 'Type', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'resource'>) => { - return {original.resource}; - }, - }, - { - id: 'path', - header: 'Path', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'path'>) => { - const { resource, name, path } = original; - if (resource === 'dashboards') { - return {path}; - } - return {path}; - }, - }, - { - id: 'hash', - header: 'Hash', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'hash'>) => { - const { hash } = original; - return {hash.substring(0, 7)}; - }, - }, - { - id: 'folder', - header: 'Folder', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'title'>) => { - const { folder } = original; - if (folder?.length) { - return {folder}; - } - return ; - }, - }, - { - id: 'actions', - header: '', - cell: ({ row: { original } }: ResourceCell) => { - const { resource, name, path } = original; - return ( - - {resource === 'dashboards' && ( - - View - - )} - {resource === 'folders' && ( - - View - - )} - {historySupported && ( - - History - - )} - - ); - }, - }, - ], - [repo.metadata?.name, historySupported, repo.spec?.type] - ); - - if (query.isLoading) { - return ( - - - - ); - } - - return ( - - - - - String(r.path)} - /> - - ); -} diff --git a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx index 07eefb59ca9..b41bcc3260f 100644 --- a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx +++ b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx @@ -4,24 +4,22 @@ import { useParams } from 'react-router-dom-v5-compat'; import { SelectableValue, urlUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Alert, EmptyState, Spinner, Tab, TabContent, TabsBar, Text, TextLink } from '@grafana/ui'; +import { Alert, EmptyState, Spinner, Stack, Tab, TabContent, TabsBar, Text, TextLink } from '@grafana/ui'; import { useGetFrontendSettingsQuery, useListRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1'; import { Page } from 'app/core/components/Page/Page'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { isNotFoundError } from 'app/features/alerting/unified/api/util'; -import { FilesView } from '../File/FilesView'; import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; import { RepositoryActions } from './RepositoryActions'; import { RepositoryOverview } from './RepositoryOverview'; -import { RepositoryResources } from './RepositoryResources'; +import { ResourceTreeView } from './ResourceTreeView'; enum TabSelection { Overview = 'overview', Resources = 'resources', - Files = 'files', } export default function RepositoryStatusPage() { @@ -50,12 +48,7 @@ export default function RepositoryStatusPage() { { value: TabSelection.Resources, label: t('provisioning.repository-status-page.tab-resources', 'Resources'), - title: t('provisioning.repository-status-page.tab-resources-title', 'Resources saved in grafana database'), - }, - { - value: TabSelection.Files, - label: t('provisioning.repository-status-page.tab-files', 'Files'), - title: t('provisioning.repository-status-page.tab-files-title', 'The raw file list from the repository'), + title: t('provisioning.repository-status-page.tab-resources-title', 'Repository files and resources'), }, ], [] @@ -99,7 +92,7 @@ export default function RepositoryStatusPage() { ) : ( <> {data ? ( - <> + {tabInfo.map((t: SelectableValue) => ( )} {tab === TabSelection.Overview && } - {tab === TabSelection.Resources && } - {tab === TabSelection.Files && } + {tab === TabSelection.Resources && } - + ) : (
not found diff --git a/public/app/features/provisioning/Repository/ResourceTreeView.tsx b/public/app/features/provisioning/Repository/ResourceTreeView.tsx new file mode 100644 index 00000000000..58282803841 --- /dev/null +++ b/public/app/features/provisioning/Repository/ResourceTreeView.tsx @@ -0,0 +1,213 @@ +import { css } from '@emotion/css'; +import { useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { + CellProps, + Column, + FilterInput, + Icon, + InteractiveTable, + Link, + LinkButton, + Spinner, + Stack, + useStyles2, +} from '@grafana/ui'; +import { + Repository, + useGetRepositoryFilesQuery, + useGetRepositoryResourcesQuery, +} from 'app/api/clients/provisioning/v0alpha1'; + +import { FlatTreeItem, TreeItem } from '../types'; +import { getRepoFileUrl } from '../utils/git'; +import { buildTree, filterTree, flattenTree, getIconName, mergeFilesAndResources } from '../utils/treeUtils'; + +interface ResourceTreeViewProps { + repo: Repository; +} + +type TreeCell = CellProps; + +function getGrafanaLink(item: TreeItem) { + if (item.resourceName) { + if (item.type === 'Dashboard') { + return `/d/${item.resourceName}`; + } + if (item.type === 'Folder') { + return `/dashboards/f/${item.resourceName}`; + } + } + return undefined; +} + +export function ResourceTreeView({ repo }: ResourceTreeViewProps) { + const styles = useStyles2(getStyles); + const name = repo.metadata?.name ?? ''; + + const filesQuery = useGetRepositoryFilesQuery({ name }); + const resourcesQuery = useGetRepositoryResourcesQuery({ name }); + + const [searchQuery, setSearchQuery] = useState(''); + + const isLoading = filesQuery.isLoading || resourcesQuery.isLoading; + + const flatItems = useMemo(() => { + const files = filesQuery.data?.items ?? []; + const resources = resourcesQuery.data?.items ?? []; + + const merged = mergeFilesAndResources(files, resources); + let tree = buildTree(merged); + + if (searchQuery) { + tree = filterTree(tree, searchQuery); + } + + return flattenTree(tree); + }, [filesQuery.data?.items, resourcesQuery.data?.items, searchQuery]); + + const columns: Array> = useMemo( + () => [ + { + id: 'title', + header: t('provisioning.resource-tree.header-title', 'Title'), + cell: ({ row: { original } }: TreeCell) => { + const { item, level } = original; + const iconName = getIconName(item.type); + const link = getGrafanaLink(item); + + return ( +
+ + {link ? {item.title} : {item.title}} +
+ ); + }, + }, + { + id: 'type', + header: t('provisioning.resource-tree.header-type', 'Type'), + cell: ({ row: { original } }: TreeCell) => { + return {original.item.type}; + }, + }, + { + id: 'status', + header: t('provisioning.resource-tree.header-status', 'Status'), + cell: ({ row: { original } }: TreeCell) => { + const { status } = original.item; + if (!status) { + return null; + } + return ( + + ); + }, + }, + { + id: 'hash', + header: t('provisioning.resource-tree.header-hash', 'Hash'), + cell: ({ row: { original } }: TreeCell) => { + const { hash } = original.item; + if (!hash) { + return null; + } + return ( + + {hash.substring(0, 7)} + + ); + }, + }, + { + id: 'actions', + header: '', + cell: ({ row: { original } }: TreeCell) => { + const { item } = original; + const isDotKeepFile = item.path.endsWith('.keep') || item.path.endsWith('.gitkeep'); + if (isDotKeepFile) { + return null; + } + + const viewLink = getGrafanaLink(item); + const sourceLink = item.hasFile ? getRepoFileUrl(repo.spec, item.path) : undefined; + + if (!viewLink && !sourceLink) { + return null; + } + + return ( + + {viewLink && ( + + View + + )} + {sourceLink && ( + + Source + + )} + + ); + }, + }, + ], + [repo.spec, styles] + ); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + item.item.path} + /> + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + titleCell: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), + icon: css({ + color: theme.colors.text.secondary, + flexShrink: 0, + }), + hash: css({ + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), + syncedIcon: css({ + color: theme.colors.success.text, + }), +}); diff --git a/public/app/features/provisioning/types.ts b/public/app/features/provisioning/types.ts index 5603df43c04..0c8dfec5dbd 100644 --- a/public/app/features/provisioning/types.ts +++ b/public/app/features/provisioning/types.ts @@ -83,7 +83,7 @@ export type AuthorInfo = { export type FileDetails = { path: string; - size: string; + size?: string; hash: string; }; @@ -98,3 +98,24 @@ export interface StatusInfo { title?: string; message?: string | string[]; } + +// Tree view types for combined Resources/Files view +export type ItemType = 'Folder' | 'File' | 'Dashboard'; +export type SyncStatus = 'synced' | 'pending'; + +export interface TreeItem { + title: string; + type: ItemType; + path: string; + level: number; + children: TreeItem[]; + resourceName?: string; + hash?: string; + status?: SyncStatus; + hasFile?: boolean; +} + +export interface FlatTreeItem { + item: TreeItem; + level: number; +} diff --git a/public/app/features/provisioning/utils/git.ts b/public/app/features/provisioning/utils/git.ts index 558f471744f..4fec609c388 100644 --- a/public/app/features/provisioning/utils/git.ts +++ b/public/app/features/provisioning/utils/git.ts @@ -17,16 +17,6 @@ export function validateBranchName(branchName?: string) { return branchName && branchNameRegex.test(branchName!); } -export const getRepoHref = (github?: RepositorySpec['github']) => { - if (!github?.url) { - return undefined; - } - if (!github.branch) { - return github.url; - } - return `${github.url}/tree/${github.branch}`; -}; - // Remove leading and trailing slashes from a string. const stripSlashes = (s: string) => s.replace(/^\/+|\/+$/g, ''); @@ -111,39 +101,106 @@ export function getHasTokenInstructions(type: RepoType): type is InstructionAvai return type === 'github' || type === 'gitlab' || type === 'bitbucket'; } -export function getRepoCommitUrl(spec?: RepositorySpec, commit?: string) { - let url: string | undefined = undefined; - let hasUrl = false; +export function getRepoFileUrl(spec?: RepositorySpec, filePath?: string) { + if (!spec || !spec.type || !filePath) { + return undefined; + } + switch (spec.type) { + case 'github': { + const { url, branch, path } = spec.github ?? {}; + if (!url) { + return undefined; + } + const fullPath = path ? `${path}${filePath}` : filePath; + return buildRepoUrl({ + baseUrl: url, + branch: branch || 'main', + providerSegments: ['blob'], + path: fullPath, + }); + } + case 'gitlab': { + const { url, branch, path } = spec.gitlab ?? {}; + if (!url) { + return undefined; + } + const fullPath = path ? `${path}${filePath}` : filePath; + return buildRepoUrl({ + baseUrl: url, + branch: branch || 'main', + providerSegments: ['-', 'blob'], + path: fullPath, + }); + } + case 'bitbucket': { + const { url, branch, path } = spec.bitbucket ?? {}; + if (!url) { + return undefined; + } + const fullPath = path ? `${path}${filePath}` : filePath; + return buildRepoUrl({ + baseUrl: url, + branch: branch || 'main', + providerSegments: ['src'], + path: fullPath, + }); + } + default: + return undefined; + } +} + +export function getRepoCommitUrl(spec?: RepositorySpec, commit?: string) { if (!spec || !spec.type || !commit) { - return { hasUrl, url }; + return { hasUrl: false, url: undefined }; } const gitType = spec.type; // local repositories don't have a URL - if (gitType !== 'local' && commit) { - switch (gitType) { - case 'github': - if (spec.github?.url) { - url = `${spec.github.url}/commit/${commit}`; - hasUrl = true; - } - break; - case 'gitlab': - if (spec.gitlab?.url) { - url = `${spec.gitlab.url}/-/commit/${commit}`; - hasUrl = true; - } - break; - case 'bitbucket': - if (spec.bitbucket?.url) { - url = `${spec.bitbucket.url}/commits/${commit}`; - hasUrl = true; - } - break; - } + if (gitType === 'local') { + return { hasUrl: false, url: undefined }; } - return { hasUrl, url }; + let url: string | undefined = undefined; + let providerSegments: string[] = []; + + switch (gitType) { + case 'github': + if (spec.github?.url) { + providerSegments = ['commit']; + url = buildRepoUrl({ + baseUrl: spec.github.url, + branch: undefined, + providerSegments, + path: commit, + }); + } + break; + case 'gitlab': + if (spec.gitlab?.url) { + providerSegments = ['-', 'commit']; + url = buildRepoUrl({ + baseUrl: spec.gitlab.url, + branch: undefined, + providerSegments, + path: commit, + }); + } + break; + case 'bitbucket': + if (spec.bitbucket?.url) { + providerSegments = ['commits']; + url = buildRepoUrl({ + baseUrl: spec.bitbucket.url, + branch: undefined, + providerSegments, + path: commit, + }); + } + break; + } + + return { hasUrl: !!url, url }; } diff --git a/public/app/features/provisioning/utils/treeUtils.test.ts b/public/app/features/provisioning/utils/treeUtils.test.ts new file mode 100644 index 00000000000..bce7c1cafb5 --- /dev/null +++ b/public/app/features/provisioning/utils/treeUtils.test.ts @@ -0,0 +1,715 @@ +import { ResourceListItem } from 'app/api/clients/provisioning/v0alpha1'; + +import { TreeItem } from '../types'; + +import { buildTree, filterTree, flattenTree, getItemType, getStatus, mergeFilesAndResources } from './treeUtils'; + +// Mock data +const mockFileDetails = { + path: 'dashboards/my-dashboard.json', + size: '1234', + hash: 'abc123def456', +}; + +const mockResource: ResourceListItem = { + path: 'dashboards/my-dashboard.json', + name: 'dashboard-uid', + title: 'My Dashboard', + resource: 'dashboards', + hash: 'abc123def456', + folder: '', + group: 'dashboard.grafana.app', +}; + +const mockFolderResource: ResourceListItem = { + path: 'dashboards', + name: 'folder-uid', + title: 'Dashboards Folder', + resource: 'folders', + hash: 'xyz789', + folder: '', + group: 'folder.grafana.app', +}; + +describe('mergeFilesAndResources', () => { + it('should merge files and resources by path', () => { + const files = [mockFileDetails]; + const resources = [mockResource]; + + const result = mergeFilesAndResources(files, resources); + + // 2 items: the file + inferred folder 'dashboards' + expect(result).toHaveLength(2); + const file = result.find((r) => r.path === 'dashboards/my-dashboard.json'); + expect(file?.file).toEqual(mockFileDetails); + expect(file?.resource).toEqual(mockResource); + + const folder = result.find((r) => r.path === 'dashboards'); + expect(folder?.file).toEqual({ path: 'dashboards', hash: '' }); + expect(folder?.resource).toBeUndefined(); + }); + + it('should handle files without matching resources', () => { + const files = [{ path: 'orphan-file.json', size: '100', hash: 'hash1' }]; + const resources: ResourceListItem[] = []; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('orphan-file.json'); + expect(result[0].file).toBeDefined(); + expect(result[0].resource).toBeUndefined(); + }); + + it('should handle resources without matching files', () => { + const files: unknown[] = []; + const resources = [mockResource]; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards/my-dashboard.json'); + expect(result[0].file).toBeUndefined(); + expect(result[0].resource).toEqual(mockResource); + }); + + it('should handle empty arrays', () => { + const result = mergeFilesAndResources([], []); + + expect(result).toHaveLength(0); + }); + + it('should filter out invalid file objects', () => { + const files = [ + mockFileDetails, + { invalid: 'object' }, // Missing path and hash + null, + undefined, + 'string', + ]; + const resources: ResourceListItem[] = []; + + const result = mergeFilesAndResources(files, resources); + + // 2 items: the file + inferred folder 'dashboards' + expect(result).toHaveLength(2); + expect(result.find((r) => r.path === 'dashboards/my-dashboard.json')).toBeDefined(); + expect(result.find((r) => r.path === 'dashboards')).toBeDefined(); + }); + + it('should skip resources with empty path (root)', () => { + const files: unknown[] = []; + const rootResource = { ...mockResource, path: '' }; + const resources = [rootResource, mockResource]; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards/my-dashboard.json'); + }); + + it('should handle folder in resources but not in files', () => { + const files = [ + { + path: 'new-dashboard-2025-10-24-NKAPX.json', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + }, + ]; + const resources: ResourceListItem[] = [ + { + path: 'new-dashboard-2025-10-24-NKAPX.json', + group: 'dashboard.grafana.app', + resource: 'dashboards', + name: 'dcf20b2odenyf4d', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + title: 'v2 dashboard', + folder: 'repository-89cac64', + }, + { + path: 'unsynced-folder', + group: 'folder.grafana.app', + resource: 'folders', + name: 'unsynced-folder-pyqothnbi8kcxjvo7tnujum7', + hash: '', + title: 'unsynced-folder', + folder: 'repository-89cac64', + }, + ]; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(2); + + const dashboard = result.find((r) => r.path === 'new-dashboard-2025-10-24-NKAPX.json'); + expect(dashboard?.file).toBeDefined(); + expect(dashboard?.resource).toBeDefined(); + + const folder = result.find((r) => r.path === 'unsynced-folder'); + expect(folder?.file).toBeUndefined(); + expect(folder?.resource).toBeDefined(); + expect(folder?.resource?.resource).toBe('folders'); + }); +}); + +describe('getItemType', () => { + it('should return Dashboard for dashboard resources', () => { + const result = getItemType('dashboards/test.json', mockResource); + + expect(result).toBe('Dashboard'); + }); + + it('should return Folder for folder resources', () => { + const result = getItemType('dashboards', mockFolderResource); + + expect(result).toBe('Folder'); + }); + + it('should return File for unsynced files regardless of extension', () => { + const result = getItemType('some/path/file.json', undefined); + + expect(result).toBe('File'); + }); + + it('should return File for non-JSON paths without resource', () => { + const result = getItemType('some/path/file.txt', undefined); + + expect(result).toBe('File'); + }); + + it('should return File when resource type is unknown', () => { + const unknownResource = { + ...mockResource, + resource: 'unknown-type', + }; + + const result = getItemType('some/path', unknownResource); + + expect(result).toBe('File'); + }); +}); + +describe('getStatus', () => { + it('should return synced when both hashes exist and match', () => { + expect(getStatus('abc123', 'abc123')).toBe('synced'); + }); + + it('should return pending when both hashes exist but differ', () => { + expect(getStatus('abc123', 'xyz789')).toBe('pending'); + }); + + it('should return pending when only file hash exists', () => { + expect(getStatus('abc123', undefined)).toBe('pending'); + }); + + it('should return pending when only resource hash exists', () => { + expect(getStatus(undefined, 'abc123')).toBe('pending'); + }); + + it('should return pending when neither hash exists', () => { + expect(getStatus(undefined, undefined)).toBe('pending'); + }); + + it('should return synced for inferred folder (empty file hash) with resource', () => { + // Empty hash means folder was inferred from file paths + expect(getStatus('', 'abc123')).toBe('synced'); + }); + + it('should return pending for inferred folder (empty file hash) without resource', () => { + expect(getStatus('', undefined)).toBe('pending'); + }); +}); + +describe('buildTree', () => { + it('should build tree with folder hierarchy', () => { + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' } }, + { path: 'folder/subfolder', file: { path: 'folder/subfolder', hash: '' } }, + { path: 'folder/subfolder/file.json', file: { path: 'folder/subfolder/file.json', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('Folder'); + expect(result[0].path).toBe('folder'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].type).toBe('Folder'); + expect(result[0].children[0].path).toBe('folder/subfolder'); + }); + + it('should place files under correct parent folders', () => { + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' } }, + { path: 'folder/file.txt', file: { path: 'folder/file.txt', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('folder'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].path).toBe('folder/file.txt'); + expect(result[0].children[0].type).toBe('File'); + }); + + it('should sort folders before files', () => { + const mergedItems = [ + { path: 'file.txt', file: { path: 'file.txt', size: '100', hash: 'h1' } }, + { path: 'folder', file: { path: 'folder', hash: '' } }, + { path: 'folder/nested.txt', file: { path: 'folder/nested.txt', size: '100', hash: 'h2' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('Folder'); + expect(result[0].title).toBe('folder'); + expect(result[1].type).toBe('File'); + expect(result[1].title).toBe('file.txt'); + }); + + it('should sort alphabetically within same type', () => { + const mergedItems = [ + { path: 'zebra.json', file: { path: 'zebra.json', size: '100', hash: 'h1' } }, + { path: 'apple.json', file: { path: 'apple.json', size: '100', hash: 'h2' } }, + { path: 'mango.json', file: { path: 'mango.json', size: '100', hash: 'h3' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(3); + expect(result[0].title).toBe('apple.json'); + expect(result[1].title).toBe('mango.json'); + expect(result[2].title).toBe('zebra.json'); + }); + + it('should handle root-level items', () => { + const mergedItems = [{ path: 'root-file.txt', file: { path: 'root-file.txt', size: '100', hash: 'h1' } }]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('root-file.txt'); + expect(result[0].type).toBe('File'); + }); + + it('should handle deeply nested paths', () => { + const mergedItems = [ + { path: 'a', file: { path: 'a', hash: '' } }, + { path: 'a/b', file: { path: 'a/b', hash: '' } }, + { path: 'a/b/c', file: { path: 'a/b/c', hash: '' } }, + { path: 'a/b/c/d', file: { path: 'a/b/c/d', hash: '' } }, + { path: 'a/b/c/d/e', file: { path: 'a/b/c/d/e', hash: '' } }, + { path: 'a/b/c/d/e/file.txt', file: { path: 'a/b/c/d/e/file.txt', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('a'); + + // Traverse to the deepest file + let current = result[0]; + const expectedPaths = ['a', 'a/b', 'a/b/c', 'a/b/c/d', 'a/b/c/d/e']; + for (let i = 0; i < expectedPaths.length; i++) { + expect(current.path).toBe(expectedPaths[i]); + expect(current.type).toBe('Folder'); + if (i < expectedPaths.length - 1) { + current = current.children[0]; + } + } + + // Check the file is in the last folder + const lastFolder = current; + expect(lastFolder.children).toHaveLength(1); + expect(lastFolder.children[0].path).toBe('a/b/c/d/e/file.txt'); + expect(lastFolder.children[0].type).toBe('File'); + }); + + it('should handle empty input', () => { + const result = buildTree([]); + + expect(result).toHaveLength(0); + }); + + it('should use resource info for folder nodes when available', () => { + const mergedItems = [ + { path: 'dashboards', resource: mockFolderResource }, + { path: 'dashboards/test.json', resource: mockResource }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Dashboards Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + }); + + it('should set synced status when file and resource hashes match', () => { + const mergedItems = [ + { + path: 'dashboard.json', + file: { path: 'dashboard.json', size: '100', hash: 'abc123def456' }, + resource: mockResource, // mockResource has hash: 'abc123def456' + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBe('synced'); + }); + + it('should set pending status when file and resource hashes differ', () => { + const mergedItems = [ + { + path: 'dashboard.json', + file: { path: 'dashboard.json', size: '100', hash: 'different-hash' }, + resource: mockResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBe('pending'); + }); + + it('should not set status for non-JSON files', () => { + const mergedItems = [{ path: 'file.txt', file: { path: 'file.txt', size: '100', hash: 'h1' } }]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBeUndefined(); + }); + + it('should show unsynced JSON files as File type with pending status', () => { + const mergedItems = [{ path: 'dashboard.json', file: { path: 'dashboard.json', size: '100', hash: 'h1' } }]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('File'); + expect(result[0].status).toBe('pending'); + }); + + it('should set pending status when only resource exists', () => { + const mergedItems = [{ path: 'dashboard.json', resource: mockResource }]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBe('pending'); + }); + + it('should set folder status to synced when all children are synced', () => { + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' }, resource: mockFolderResource }, + { + path: 'folder/dashboard1.json', + file: { path: 'folder/dashboard1.json', size: '100', hash: 'matching-hash' }, + resource: syncedResource, + }, + { + path: 'folder/dashboard2.json', + file: { path: 'folder/dashboard2.json', size: '100', hash: 'matching-hash' }, + resource: { ...syncedResource, name: 'other-uid' }, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('synced'); + }); + + it('should set folder status to pending when any child is pending', () => { + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'folder', resource: mockFolderResource }, + { + path: 'folder/dashboard1.json', + file: { path: 'folder/dashboard1.json', size: '100', hash: 'matching-hash' }, + resource: syncedResource, + }, + { + path: 'folder/dashboard2.json', + file: { path: 'folder/dashboard2.json', size: '100', hash: 'different-hash' }, + resource: syncedResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('pending'); + }); + + it('should propagate pending status from nested folders', () => { + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'parent', file: { path: 'parent', hash: '' } }, + { path: 'parent/child', file: { path: 'parent/child', hash: '' } }, + { + path: 'parent/child/dashboard.json', + file: { path: 'parent/child/dashboard.json', size: '100', hash: 'different-hash' }, + resource: syncedResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].path).toBe('parent'); + expect(result[0].status).toBe('pending'); + expect(result[0].children[0].path).toBe('parent/child'); + expect(result[0].children[0].status).toBe('pending'); + }); + + it('should set pending status for unsynced folders with no dashboard children', () => { + const mergedItems = [ + { + path: 'unsynced-folder', + resource: { + path: 'unsynced-folder', + group: 'folder.grafana.app', + resource: 'folders', + name: 'unsynced-folder-pyqothnbi8kcxjvo7tnujum7', + hash: '', + title: 'unsynced-folder', + folder: 'repository-89cac64', + }, + }, + { + path: 'new-dashboard-2025-10-24-NKAPX.json', + file: { + path: 'new-dashboard-2025-10-24-NKAPX.json', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + }, + resource: { + path: 'new-dashboard-2025-10-24-NKAPX.json', + group: 'dashboard.grafana.app', + resource: 'dashboards', + name: 'dcf20b2odenyf4d', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + title: 'v2 dashboard', + folder: 'repository-89cac64', + }, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].status).toBe('pending'); + }); + + it('should set pending status for folder in resources but not in files', () => { + // Folder only exists in resources (e.g., deleted from repo but not synced yet) + const mergedItems = [ + { path: 'folder', resource: mockFolderResource }, + { path: 'folder/file.txt', file: { path: 'folder/file.txt', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('pending'); + }); + + it('should set synced status for folder inferred from files with matching resource', () => { + // Folder inferred from file paths AND exists in resources → synced + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' }, resource: mockFolderResource }, + { + path: 'folder/dashboard.json', + file: { path: 'folder/dashboard.json', size: '100', hash: 'matching-hash' }, + resource: syncedResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('synced'); + }); +}); + +describe('flattenTree', () => { + it('should flatten nested tree structure', () => { + const tree: TreeItem[] = [ + { + path: 'folder', + title: 'Folder', + type: 'Folder', + level: 0, + children: [ + { + path: 'folder/file.json', + title: 'file.json', + type: 'File', + level: 0, + children: [], + }, + ], + }, + ]; + + const result = flattenTree(tree); + + expect(result).toHaveLength(2); + expect(result[0].item.path).toBe('folder'); + expect(result[1].item.path).toBe('folder/file.json'); + }); + + it('should set correct level for each item', () => { + const tree: TreeItem[] = [ + { + path: 'folder', + title: 'Folder', + type: 'Folder', + level: 0, + children: [ + { + path: 'folder/subfolder', + title: 'Subfolder', + type: 'Folder', + level: 0, + children: [ + { + path: 'folder/subfolder/file.json', + title: 'file.json', + type: 'File', + level: 0, + children: [], + }, + ], + }, + ], + }, + ]; + + const result = flattenTree(tree); + + expect(result).toHaveLength(3); + expect(result[0].level).toBe(0); + expect(result[1].level).toBe(1); + expect(result[2].level).toBe(2); + }); + + it('should include all children', () => { + const tree: TreeItem[] = [ + { + path: 'folder', + title: 'Folder', + type: 'Folder', + level: 0, + children: [ + { path: 'folder/a.json', title: 'a.json', type: 'File', level: 0, children: [] }, + { path: 'folder/b.json', title: 'b.json', type: 'File', level: 0, children: [] }, + { path: 'folder/c.json', title: 'c.json', type: 'File', level: 0, children: [] }, + ], + }, + ]; + + const result = flattenTree(tree); + + expect(result).toHaveLength(4); + }); + + it('should handle empty tree', () => { + const result = flattenTree([]); + + expect(result).toHaveLength(0); + }); +}); + +describe('filterTree', () => { + const sampleTree: TreeItem[] = [ + { + path: 'dashboards', + title: 'Dashboards', + type: 'Folder', + level: 0, + children: [ + { + path: 'dashboards/monitoring.json', + title: 'System Monitoring', + type: 'Dashboard', + level: 0, + children: [], + }, + { + path: 'dashboards/sales.json', + title: 'Sales Report', + type: 'Dashboard', + level: 0, + children: [], + }, + ], + }, + { + path: 'config.json', + title: 'config.json', + type: 'File', + level: 0, + children: [], + }, + ]; + + it('should return all items when query is empty', () => { + const result = filterTree(sampleTree, ''); + + expect(result).toEqual(sampleTree); + }); + + it('should filter by path (case-insensitive)', () => { + const result = filterTree(sampleTree, 'MONITORING'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].path).toBe('dashboards/monitoring.json'); + }); + + it('should filter by title (case-insensitive)', () => { + const result = filterTree(sampleTree, 'sales report'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].title).toBe('Sales Report'); + }); + + it('should include parent folders when child matches', () => { + const result = filterTree(sampleTree, 'monitoring'); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('Folder'); + expect(result[0].path).toBe('dashboards'); + expect(result[0].children).toHaveLength(1); + }); + + it('should return empty array when nothing matches', () => { + const result = filterTree(sampleTree, 'nonexistent'); + + expect(result).toHaveLength(0); + }); + + it('should match folder itself if query matches folder name', () => { + const result = filterTree(sampleTree, 'dashboards'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards'); + // When folder matches, all children are included + expect(result[0].children).toHaveLength(2); + }); + + it('should match root level items', () => { + const result = filterTree(sampleTree, 'config'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('config.json'); + }); +}); diff --git a/public/app/features/provisioning/utils/treeUtils.ts b/public/app/features/provisioning/utils/treeUtils.ts new file mode 100644 index 00000000000..9cf56580a98 --- /dev/null +++ b/public/app/features/provisioning/utils/treeUtils.ts @@ -0,0 +1,229 @@ +import { IconName } from '@grafana/ui'; +import { ResourceListItem } from 'app/api/clients/provisioning/v0alpha1'; + +import { FileDetails, FlatTreeItem, ItemType, SyncStatus, TreeItem } from '../types'; + +const collator = new Intl.Collator(); + +interface MergedItem { + path: string; + file?: FileDetails; + resource?: ResourceListItem; +} + +function isFileDetails(obj: unknown): obj is FileDetails { + return typeof obj === 'object' && obj !== null && 'path' in obj && 'hash' in obj; +} + +export function mergeFilesAndResources(files: unknown[], resources: ResourceListItem[]): MergedItem[] { + const merged = new Map(); + const inferredFolders = new Set(); + + for (const file of files) { + if (isFileDetails(file)) { + merged.set(file.path, { path: file.path, file }); + + // Infer parent folders from file path + const parts = file.path.split('/'); + for (let i = 1; i < parts.length; i++) { + inferredFolders.add(parts.slice(0, i).join('/')); + } + } + } + + // Add inferred folders that don't already exist + for (const folderPath of inferredFolders) { + if (!merged.has(folderPath)) { + merged.set(folderPath, { path: folderPath, file: { path: folderPath, hash: '' } }); + } + } + + // Merge resources + for (const resource of resources) { + if (!resource.path) { + continue; + } + const existing = merged.get(resource.path); + if (existing) { + existing.resource = resource; + } else { + merged.set(resource.path, { path: resource.path, resource }); + } + } + + return Array.from(merged.values()); +} + +export function getItemType(path: string, resource?: ResourceListItem): ItemType { + if (resource?.resource === 'dashboards') { + return 'Dashboard'; + } + if (resource?.resource === 'folders') { + return 'Folder'; + } + // Inferred folder (no extension means it's a folder from file paths) + if (!resource && !path.includes('.')) { + return 'Folder'; + } + // Unsynced files are "File" - don't infer Dashboard from .json + return 'File'; +} + +export function getDisplayTitle(path: string, resource?: ResourceListItem): string { + if (resource?.title) { + return resource.title; + } + return path.split('/').pop() ?? path; +} + +export function getIconName(type: ItemType): IconName { + switch (type) { + case 'Folder': + return 'folder'; + case 'Dashboard': + return 'apps'; + case 'File': + default: + return 'file-alt'; + } +} + +export function getStatus(fileHash?: string, resourceHash?: string): SyncStatus { + if (fileHash !== undefined && resourceHash !== undefined) { + // Empty file hash means inferred folder (synced if resource exists) + return fileHash === '' || fileHash === resourceHash ? 'synced' : 'pending'; + } + return 'pending'; +} + +function calculateFolderStatus(node: TreeItem): SyncStatus | undefined { + if (node.type !== 'Folder') { + return node.status; + } + + // If any child is pending, folder is pending + for (const child of node.children) { + const childStatus = child.type === 'Folder' ? calculateFolderStatus(child) : child.status; + if (childStatus === 'pending') { + return 'pending'; + } + } + + return node.status; +} + +export function buildTree(mergedItems: MergedItem[]): TreeItem[] { + const nodeMap = new Map(); + const roots: TreeItem[] = []; + + // Create all nodes (files, dashboards, folders) + for (const item of mergedItems) { + const type = getItemType(item.path, item.resource); + const showStatus = type === 'Dashboard' || type === 'Folder' || item.path.endsWith('.json'); + + nodeMap.set(item.path, { + path: item.path, + title: getDisplayTitle(item.path, item.resource), + type, + level: 0, + children: [], + resourceName: item.resource?.name, + hash: item.file?.hash ?? item.resource?.hash, + status: showStatus ? getStatus(item.file?.hash, item.resource?.hash) : undefined, + hasFile: !!item.file, + }); + } + + // Build parent-child relationships + for (const [path, node] of nodeMap) { + const lastSlashIndex = path.lastIndexOf('/'); + if (lastSlashIndex === -1) { + roots.push(node); + } else { + const parentPath = path.substring(0, lastSlashIndex); + const parent = nodeMap.get(parentPath); + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } + } + + // Sort: folders first, then alphabetically, recursively + const sortNodes = (nodes: TreeItem[]) => { + nodes.sort((a, b) => { + if (a.type === 'Folder' && b.type !== 'Folder') { + return -1; + } + if (a.type !== 'Folder' && b.type === 'Folder') { + return 1; + } + return collator.compare(a.title, b.title); + }); + for (const node of nodes) { + sortNodes(node.children); + } + }; + + sortNodes(roots); + + // Update folder statuses recursively (folders inherit pending from children) + const updateFolderStatus = (nodes: TreeItem[]) => { + for (const node of nodes) { + if (node.type === 'Folder') { + updateFolderStatus(node.children); + node.status = calculateFolderStatus(node); + } + } + }; + + updateFolderStatus(roots); + return roots; +} + +export function flattenTree(items: TreeItem[], level = 0): FlatTreeItem[] { + const result: FlatTreeItem[] = []; + + for (const item of items) { + result.push({ + item: { ...item, level }, + level, + }); + + if (item.children.length > 0) { + result.push(...flattenTree(item.children, level + 1)); + } + } + + return result; +} + +/** + * Filter tree by search query (searches path and title). + * Returns filtered tree including ancestor folders for matching items. + */ +export function filterTree(items: TreeItem[], searchQuery: string): TreeItem[] { + if (!searchQuery) { + return items; + } + + const lowerQuery = searchQuery.toLowerCase(); + + const filterNode = (node: TreeItem): TreeItem | null => { + const matches = node.path.toLowerCase().includes(lowerQuery) || node.title.toLowerCase().includes(lowerQuery); + + if (matches) { + return node; + } + + if (node.type === 'Folder' && node.children.length > 0) { + const filteredChildren = node.children.map(filterNode).filter((n): n is TreeItem => n !== null); + return filteredChildren.length > 0 ? { ...node, children: filteredChildren } : null; + } + + return null; + }; + + return items.map(filterNode).filter((n): n is TreeItem => n !== null); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5a55ab0843f..a8ed1bdf15c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Saving", "title-error-loading-file": "Error loading file" }, - "files-view": { - "columns": { - "history": "History", - "view": "View" - }, - "placeholder-search": "Search" - }, "finish-step": { "description-enable-previews": "Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards will be shared in your Git repository and visible to anyone with repository access.", "description-generate-dashboard-previews": "Create preview links for pull requests", @@ -11956,14 +11949,6 @@ "webhook-last-event": "Last Event:", "webhook-url": "View Webhook" }, - "repository-resources": { - "columns": { - "history": "History", - "view-dashboard": "View", - "view-folder": "View" - }, - "placeholder-search": "Search" - }, "repository-status-page": { "back-to-repositories": "Back to repositories", "cleaning-up-resources": "Cleaning up repository resources", @@ -11971,12 +11956,10 @@ "not-found": "not found", "not-found-message": "Repository not found", "repository-config-exists-configuration": "Make sure the repository config exists in the configuration file.", - "tab-files": "Files", - "tab-files-title": "The raw file list from the repository", "tab-overview": "Overview", "tab-overview-title": "Repository overview", "tab-resources": "Resources", - "tab-resources-title": "Resources saved in grafana database", + "tab-resources-title": "Repository files and resources", "title": "Repository Status", "title-legacy-storage": "Legacy Storage", "title-queued-for-deletion": "Queued for deletion" @@ -11999,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Connect to any Git repository" }, + "resource-tree": { + "header-hash": "Hash", + "header-status": "Status", + "header-title": "Title", + "header-type": "Type", + "search-placeholder": "Search by path or title", + "source": "Source", + "status-pending": "Pending", + "status-synced": "Synced", + "view": "View" + }, "resource-view": { "base": "Base", "dashboard-preview": "Dashboard Preview", From 1c8f4a745f5b31ebd5399d16a0b2d501f731f6e2 Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 11:47:52 +0100 Subject: [PATCH 02/31] chore(deps): update dependency node-forge to v1.3.2 [security] (#114522) --- yarn.lock | 113 +++++++----------------------------------------------- 1 file changed, 13 insertions(+), 100 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2022568eb8b..91be1a1621f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4251,7 +4251,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/type@npm:^3.0.10": +"@inquirer/type@npm:^3.0.10, @inquirer/type@npm:^3.0.9": version: 3.0.10 resolution: "@inquirer/type@npm:3.0.10" peerDependencies: @@ -4263,18 +4263,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/type@npm:^3.0.9": - version: 3.0.9 - resolution: "@inquirer/type@npm:3.0.9" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/960ba4737405f70bac17e7cdc4696c60064b06c8dd13a4b3d0783763ba1714bdadbd598b88d537ab9415b7d5d61e011ac042cfbd1438b2a35298e2868724b853 - languageName: node - linkType: hard - "@internationalized/date@npm:^3.10.0": version: 3.10.0 resolution: "@internationalized/date@npm:3.10.0" @@ -5038,14 +5026,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15, @jridgewell/sourcemap-codec@npm:^1.5.0": - version: 1.5.0 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" - checksum: 10/4ed6123217569a1484419ac53f6ea0d9f3b57e5b57ab30d7c267bdb27792a27eb0e4b08e84a2680aa55cc2f2b411ffd6ec3db01c44fdc6dc43aca4b55f8374fd - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.5.5": +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": version: 1.5.5 resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 @@ -8146,17 +8127,7 @@ __metadata: languageName: node linkType: hard -"@storybook/icons@npm:^1.2.12": - version: 1.2.12 - resolution: "@storybook/icons@npm:1.2.12" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 10/5df56f0856764ed7e4bb24ef7a08a8a9c93f8eedcb16dac062f1dfd3bd1fe6cb4a0aa5a0794083d95e31c04960d126a4d2028cfb4c53681bf05513bb38eae9d2 - languageName: node - linkType: hard - -"@storybook/icons@npm:^1.6.0": +"@storybook/icons@npm:^1.2.12, @storybook/icons@npm:^1.6.0": version: 1.6.0 resolution: "@storybook/icons@npm:1.6.0" peerDependencies: @@ -9281,7 +9252,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:1.15.2, @swc/core@npm:^1.13.5": +"@swc/core@npm:1.15.2": version: 1.15.2 resolution: "@swc/core@npm:1.15.2" dependencies: @@ -9327,7 +9298,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:^1.10.8, @swc/core@npm:^1.5.22": +"@swc/core@npm:^1.10.8, @swc/core@npm:^1.13.5, @swc/core@npm:^1.5.22": version: 1.15.3 resolution: "@swc/core@npm:1.15.3" dependencies: @@ -13540,20 +13511,7 @@ __metadata: languageName: node linkType: hard -"chai@npm:^5.1.1": - version: 5.2.0 - resolution: "chai@npm:5.2.0" - dependencies: - assertion-error: "npm:^2.0.1" - check-error: "npm:^2.1.1" - deep-eql: "npm:^5.0.1" - loupe: "npm:^3.1.0" - pathval: "npm:^2.0.0" - checksum: 10/2ce03671c159c6a567bf1912756daabdbb7c075f3c0078f1b59d61da8d276936367ee696dfe093b49e1479d9ba93a6074c8e55d49791dddd8061728cdcad249e - languageName: node - linkType: hard - -"chai@npm:^5.2.0": +"chai@npm:^5.1.1, chai@npm:^5.2.0": version: 5.3.3 resolution: "chai@npm:5.3.3" dependencies: @@ -18451,18 +18409,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^11.1.1, fs-extra@npm:^11.2.0": - version: 11.3.0 - resolution: "fs-extra@npm:11.3.0" - dependencies: - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^6.0.1" - universalify: "npm:^2.0.0" - checksum: 10/c9fe7b23dded1efe7bbae528d685c3206477e20cc60e9aaceb3f024f9b9ff2ee1f62413c161cb88546cc564009ab516dec99e9781ba782d869bb37e4fe04a97f - languageName: node - linkType: hard - -"fs-extra@npm:^11.3.2": +"fs-extra@npm:^11.1.1, fs-extra@npm:^11.2.0, fs-extra@npm:^11.3.2": version: 11.3.2 resolution: "fs-extra@npm:11.3.2" dependencies: @@ -23543,14 +23490,7 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.0, loupe@npm:^3.1.1, loupe@npm:^3.1.2": - version: 3.1.3 - resolution: "loupe@npm:3.1.3" - checksum: 10/9e98c34daf0eba48ccc603595e51f2ae002110982d84879cf78c51de2c632f0c571dfe82ce4210af60c32203d06b443465c269bda925076fe6d9b612cc65c321 - languageName: node - linkType: hard - -"loupe@npm:^3.1.4": +"loupe@npm:^3.1.0, loupe@npm:^3.1.1, loupe@npm:^3.1.2, loupe@npm:^3.1.4": version: 3.2.1 resolution: "loupe@npm:3.2.1" checksum: 10/a4d78ec758aaa04e0e35d5cd1c15e970beb9cdbfd3d0f34f98b9bcda489f896a7190b3b6cc40b7a6dcb8e97e82e96eafaae10096aaa469804acdba6f7c2bde5f @@ -23645,7 +23585,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.17": +"magic-string@npm:^0.30.17, magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -23654,15 +23594,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": - version: 0.30.17 - resolution: "magic-string@npm:0.30.17" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.0" - checksum: 10/2f71af2b0afd78c2e9012a29b066d2c8ba45a9cd0c8070f7fd72de982fb1c403b4e3afdb1dae00691d56885ede66b772ef6bedf765e02e3a7066208fe2fec4aa - languageName: node - linkType: hard - "mailparser@npm:^3.5.0": version: 3.7.1 resolution: "mailparser@npm:3.7.1" @@ -24889,9 +24820,9 @@ __metadata: linkType: hard "node-forge@npm:^1.3.1": - version: 1.3.1 - resolution: "node-forge@npm:1.3.1" - checksum: 10/05bab6868633bf9ad4c3b1dd50ec501c22ffd69f556cdf169a00998ca1d03e8107a6032ba013852f202035372021b845603aeccd7dfcb58cdb7430013b3daa8d + version: 1.3.2 + resolution: "node-forge@npm:1.3.2" + checksum: 10/dcc54aaffe0cf52367214a20c0032aa9b209d9095dd14526504f1972d1900a07e96046b3684cb0c8d0cc3d48744dd18e02b7b447ab28fac615ffb850beeabf18 languageName: node linkType: hard @@ -28322,25 +28253,7 @@ __metadata: languageName: node linkType: hard -"react-docgen@npm:^7.0.0": - version: 7.0.3 - resolution: "react-docgen@npm:7.0.3" - dependencies: - "@babel/core": "npm:^7.18.9" - "@babel/traverse": "npm:^7.18.9" - "@babel/types": "npm:^7.18.9" - "@types/babel__core": "npm:^7.18.0" - "@types/babel__traverse": "npm:^7.18.0" - "@types/doctrine": "npm:^0.0.9" - "@types/resolve": "npm:^1.20.2" - doctrine: "npm:^3.0.0" - resolve: "npm:^1.22.1" - strip-indent: "npm:^4.0.0" - checksum: 10/53eaed76cceb55606584c6ab603f04ec78c066cfb9ed983e1f7b388a75bfb8c2fc9c6b7ab299bac311b3daeca95adb8076b58ca96b41907b33c518299268831f - languageName: node - linkType: hard - -"react-docgen@npm:^7.1.1": +"react-docgen@npm:^7.0.0, react-docgen@npm:^7.1.1": version: 7.1.1 resolution: "react-docgen@npm:7.1.1" dependencies: From 95174454e3873a53efc94e012784d905d6dccd84 Mon Sep 17 00:00:00 2001 From: "Marc M." <146180665+grafakus@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:05:15 +0100 Subject: [PATCH 03/31] ConditionalRendering: Fix for repeated items (#114160) --- ...-conditional-rendering-load-change.spec.ts | 52 +++- e2e-playwright/dashboard-new-layouts/utils.ts | 14 +- .../DashboardWithAllConditionalRendering.json | 271 ++++++++++++++++++ .../conditions/ConditionalRenderingData.tsx | 19 +- .../ConditionalRenderingTimeRangeSize.tsx | 4 + .../ConditionalRenderingVariable.tsx | 36 ++- .../conditional-rendering/conditions/utils.ts | 7 + .../group/ConditionalRenderingGroup.tsx | 23 +- .../hooks/useIsConditionallyHidden.tsx | 14 +- .../conditional-rendering/object.ts | 4 +- .../scene/layout-auto-grid/AutoGridItem.tsx | 17 +- .../layout-auto-grid/AutoGridItemRenderer.tsx | 39 +-- .../scene/layout-rows/RowItemRenderer.tsx | 5 +- .../scene/layout-rows/RowItemRepeater.tsx | 3 + .../scene/layout-rows/RowsLayoutManager.tsx | 2 + .../scene/layout-tabs/TabItemRenderer.tsx | 6 +- .../scene/layout-tabs/TabItemRepeater.tsx | 3 + .../scene/layout-tabs/TabsLayoutManager.tsx | 2 + 18 files changed, 468 insertions(+), 53 deletions(-) diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts index 56c46ad8d68..a6f52738ff8 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts @@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage, DashboardPageArgs } fro import testDashboard from '../dashboards/DashboardWithAllConditionalRendering.json'; +import { checkRepeatedPanelTitles } from './utils'; + test.use({ featureToggles: { kubernetesDashboards: true, @@ -93,7 +95,7 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d test.afterAll(async ({ request }) => { if (uid) { - await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/${uid}`); + await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/stacks-12345/dashboards/${uid}`); } }); @@ -407,4 +409,52 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d await expect(getTabShowNotMatches(dashboardPage, selectors)).toBeVisible(); await expect(getTabHideNotMatches(dashboardPage, selectors)).not.toBeVisible(); }); + + test.describe('Variable repeat', () => { + const repeatOptions = ['a', 'b', 'c']; + + async function failTestDataRequestForOption(page: Page, option: string) { + await page.route(/\/api\/ds\/query\?.*\bds_type=grafana-testdata-datasource/, async (route) => { + const rawPostData = route.request().postData(); + if (!rawPostData) { + return; + } + + // the first panel query has a label set to the current variable value + if (JSON.parse(rawPostData).queries[0].labels === `key=${option}`) { + await route.fulfill({ status: 500, body: '{}' }); + } else { + await route.continue(); + } + }); + } + + test('Hide when equals, hide when no data', async ({ page, gotoDashboardPage, selectors }) => { + const dashboardPage = await loadDashboard(page, gotoDashboardPage); + + await getTab(dashboardPage, selectors, 'repeated items').click(); + + const optionForHiddenPanels = repeatOptions[0]; + + await failTestDataRequestForOption(page, optionForHiddenPanels); + + await checkRepeatedPanelTitles( + dashboardPage, + selectors, + 'Hide panel - ', + [ + `custom variable equals ${optionForHiddenPanels} (current = ${optionForHiddenPanels})`, + `no data (current = ${optionForHiddenPanels})`, + ], + true + ); + + const optionsForVisiblePanels = repeatOptions.slice(1); + + await checkRepeatedPanelTitles(dashboardPage, selectors, 'Hide panel - ', [ + ...optionsForVisiblePanels.map((o) => `custom variable equals ${optionForHiddenPanels} (current = ${o})`), + ...optionsForVisiblePanels.map((o) => `no data (current = ${o})`), + ]); + }); + }); }); diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index c1e00e1a67b..89f4de0eec3 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -97,12 +97,18 @@ export async function checkRepeatedPanelTitles( dashboardPage: DashboardPage, selectors: E2ESelectorGroups, title: string, - options: Array + options: Array, + expectHidden = false ) { for (const option of options) { - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${title}${option}`)) - ).toBeVisible(); + const titleLocator = dashboardPage.getByGrafanaSelector( + selectors.components.Panels.Panel.title(`${title}${option}`) + ); + if (expectHidden) { + await expect(titleLocator).toBeHidden(); + } else { + await expect(titleLocator).toBeVisible(); + } } } diff --git a/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json b/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json index f0d81daa233..522a5d670ac 100644 --- a/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json +++ b/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json @@ -3308,6 +3308,170 @@ } } }, + "panel-37": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "group": "", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 37, + "links": [], + "title": "Hide panel - custom variable equals a (current = ${myCustomVariable})", + "vizConfig": { + "group": "text", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "", + "mode": "markdown" + } + }, + "version": "12.2.0-pre" + } + } + }, + "panel-38": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "PD8C576611E62080A" + }, + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": { + "labels": "key=$myCustomVariable", + "scenarioId": "random_walk", + "seriesCount": 1 + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 38, + "links": [], + "title": "Hide panel - no data (current = ${myCustomVariable})", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "12.2.0-pre" + } + } + }, "panel-4": { "kind": "Panel", "spec": { @@ -5091,6 +5255,80 @@ }, "title": "Tab - hide - time range <7d" } + }, + { + "kind": "TabsLayoutTab", + "spec": { + "layout": { + "kind": "AutoGridLayout", + "spec": { + "columnWidthMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingVariable", + "spec": { + "operator": "equals", + "value": "a", + "variable": "myCustomVariable" + } + } + ], + "visibility": "hide" + } + }, + "element": { + "kind": "ElementReference", + "name": "panel-37" + }, + "repeat": { + "mode": "variable", + "value": "myCustomVariable" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingData", + "spec": { + "value": false + } + } + ], + "visibility": "hide" + } + }, + "element": { + "kind": "ElementReference", + "name": "panel-38" + }, + "repeat": { + "mode": "variable", + "value": "myCustomVariable" + } + } + } + ], + "maxColumnCount": 3, + "rowHeightMode": "standard" + } + }, + "title": "Tab - repeated items" + } } ] } @@ -5122,6 +5360,39 @@ "query": "", "skipUrlSync": false } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": false, + "current": { + "text": "All", + "value": "$__all" + }, + "hide": "dontHide", + "includeAll": true, + "multi": false, + "name": "myCustomVariable", + "options": [ + { + "selected": false, + "text": "a", + "value": "a" + }, + { + "selected": false, + "text": "b", + "value": "b" + }, + { + "selected": false, + "text": "c", + "value": "c" + } + ], + "query": "a, b, c", + "skipUrlSync": false + } } ] }, diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx index bc1078b7f1b..a7941569a0a 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx @@ -68,22 +68,29 @@ export class ConditionalRenderingData extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx index a717b68ea79..d22ad02b8f9 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx @@ -80,6 +80,10 @@ export class ConditionalRenderingTimeRangeSize extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx index 8f82b1f459f..827f7d9ca39 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx @@ -20,7 +20,7 @@ import { getLowerTranslatedObjectType } from '../object'; import { ConditionalRenderingConditionWrapper } from './ConditionalRenderingConditionWrapper'; import { ConditionalRenderingConditionsSerializerRegistryItem } from './serializers'; -import { checkGroup, getObjectType } from './utils'; +import { checkGroup, getObject, getObjectType } from './utils'; type VariableConditionValueOperator = '=' | '!=' | '=~' | '!~'; @@ -40,14 +40,6 @@ export class ConditionalRenderingVariable extends SceneObjectBase { - if (v.state.name === this.state.variable) { - this._check(); - } - }, - }); - public constructor(state: ConditionalRenderingVariableState) { super(state); @@ -55,6 +47,20 @@ export class ConditionalRenderingVariable extends SceneObjectBase { + if (v.state.name === this.state.variable) { + this._check(); + } + }, + }); + this.forEachChild((child) => { if (!child.isActive) { this._subs.add(child.activate()); @@ -78,7 +84,13 @@ export class ConditionalRenderingVariable extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts b/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts index af8a4110a11..6ce89e17610 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts @@ -14,6 +14,13 @@ export function getGroup(condition: ConditionalRenderingConditions): Conditional } export function getObject(condition: ConditionalRenderingConditions): SceneObject | undefined { + const group = getGroup(condition); + const groupTarget = group.getTarget(); + + if (groupTarget) { + return groupTarget; + } + return getGroup(condition).parent; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx b/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx index 5f0b3319e25..8343bd1914c 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx @@ -2,7 +2,14 @@ import { lowerCase } from 'lodash'; import { useMemo } from 'react'; import { t } from '@grafana/i18n'; -import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { + SceneComponentProps, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, +} from '@grafana/scenes'; import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Stack } from '@grafana/ui'; @@ -33,6 +40,7 @@ export class ConditionalRenderingGroup extends SceneObjectBase; public constructor(state: ConditionalRenderingGroupState) { super(state); @@ -52,6 +60,19 @@ export class ConditionalRenderingGroup extends SceneObjectBase condition.forceCheck()); + } + public check() { // Filter out undefined results // Because we negate the result if shouldShow is false, we can use `condition.state.result ?? true` directly below diff --git a/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx b/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx index 2292b63500a..b8b77fd44d6 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx @@ -1,12 +1,13 @@ import { ReactNode } from 'react'; -import { SceneObject, useSceneObjectState } from '@grafana/scenes'; +import { useSceneObjectState } from '@grafana/scenes'; import { ConditionalRenderingGroup } from '../group/ConditionalRenderingGroup'; import { ConditionalRenderingOverlay } from './ConditionalRenderingOverlay'; let placeholderConditionalRendering: ConditionalRenderingGroup | undefined; + function getPlaceholderConditionalRendering(): ConditionalRenderingGroup { if (!placeholderConditionalRendering) { placeholderConditionalRendering = ConditionalRenderingGroup.createEmpty(); @@ -14,13 +15,10 @@ function getPlaceholderConditionalRendering(): ConditionalRenderingGroup { return placeholderConditionalRendering; } -export function useIsConditionallyHidden(scene: SceneObject): [boolean, string | undefined, ReactNode | null, boolean] { - const conditionalRenderingToRender = - 'conditionalRendering' in scene.state && scene.state.conditionalRendering instanceof ConditionalRenderingGroup - ? scene.state.conditionalRendering - : getPlaceholderConditionalRendering(); - - const { result, renderHidden } = useSceneObjectState(conditionalRenderingToRender, { +export function useIsConditionallyHidden( + conditionalRendering: ConditionalRenderingGroup = getPlaceholderConditionalRendering() +): [boolean, string | undefined, ReactNode | null, boolean] { + const { result, renderHidden } = useSceneObjectState(conditionalRendering, { shouldActivateOrKeepAlive: true, }); diff --git a/public/app/features/dashboard-scene/conditional-rendering/object.ts b/public/app/features/dashboard-scene/conditional-rendering/object.ts index 67fca6942be..f29ad53c231 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/object.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/object.ts @@ -1,7 +1,7 @@ import { capitalize, lowerCase } from 'lodash'; import { t } from '@grafana/i18n'; -import { SceneObject } from '@grafana/scenes'; +import { SceneObject, VizPanel } from '@grafana/scenes'; import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem'; import { RowItem } from '../scene/layout-rows/RowItem'; @@ -50,7 +50,7 @@ export function getLowerTranslatedObjectType(type: ObjectsWithConditionalRenderi export function extractObjectType(object: SceneObject | undefined): ObjectsWithConditionalRendering { if (!object) { return 'element'; - } else if (object instanceof AutoGridItem) { + } else if (object instanceof AutoGridItem || object instanceof VizPanel) { return 'panel'; } else if (object instanceof RowItem) { return 'row'; diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index 5a88e451fb0..3b7b6cf03d2 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -31,6 +31,7 @@ export interface AutoGridItemState extends SceneObjectState { variableName?: string; isHidden?: boolean; conditionalRendering?: ConditionalRenderingGroup; + repeatedConditionalRendering?: ConditionalRenderingGroup[]; } export class AutoGridItem extends SceneObjectBase implements DashboardLayoutItem { @@ -130,7 +131,21 @@ export class AutoGridItem extends SceneObjectBase implements } } - this.setState({ repeatedPanels }); + let repeatedConditionalRendering: ConditionalRenderingGroup[] | undefined; + + if (this.state.conditionalRendering) { + repeatedConditionalRendering = repeatedPanels.reduce((acc, panel) => { + const conditionalRendering = this.state.conditionalRendering!.clone(); + conditionalRendering.setTarget(panel); + acc.push(conditionalRendering); + + return acc; + }, []); + + this.state.conditionalRendering.setTarget(panelToRepeat); + } + + this.setState({ repeatedPanels, repeatedConditionalRendering }); this._prevRepeatValues = values; } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index a41fb80a233..28fd18768d2 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data/'; import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; +import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext'; @@ -17,8 +18,6 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps getIsLazy(preload), [preload]); @@ -29,18 +28,23 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps - isConditionallyHidden && !isEditing && !renderHidden ? null : ( + }) => { + const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] = + useIsConditionallyHidden(conditionalRendering); + + return isConditionallyHidden && !isEditing && !renderHidden ? null : (
- ) + ); + } ), - [ - conditionalRenderingClass, - conditionalRenderingOverlay, - isLazy, - key, - model.containerRef, - styles, - isConditionallyHidden, - isEditing, - renderHidden, - ] + [model, isLazy, key, styles, isEditing] ); if (soloPanelContext) { @@ -102,10 +97,18 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps - - {repeatedPanels.map((item) => ( + + {repeatedPanels.map((item, idx) => ( ) { const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState(); const isClone = isRepeatCloneOrChildOf(model); const { isEditing } = useDashboardState(model); - const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = - useIsConditionallyHidden(model); + const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden( + model.state.conditionalRendering + ); const { isSelected, onSelect, isSelectable } = useElementSelection(key); const title = useInterpolatedTitle(model); const { rows } = model.getParentLayout().useState(); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx index 0e18ffd7ef0..7de374bc5cf 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx @@ -112,7 +112,10 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co }); if (!isSourceRow) { + rowClone.state.conditionalRendering?.setTarget(rowClone); clonedRows.push(rowClone); + } else { + row.state.conditionalRendering?.setTarget(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 49c3e0fd0a9..778aad91757 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -292,6 +292,8 @@ export class RowsLayoutManager extends SceneObjectBase i const conditionalRendering = tab.state.conditionalRendering; conditionalRendering?.clearParent(); + // We need to clear the target since we don't want to point the original tab anymore (if it was set) + conditionalRendering?.setTarget(undefined); rows.push( new RowItem({ diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index 6ddd916cad2..c9af2bc439c 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -29,7 +29,7 @@ export function TabItemRenderer({ model }: SceneComponentProps) { const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug })); const styles = useStyles2(getStyles); const pointerDistance = usePointerDistance(); - const [isConditionallyHidden] = useIsConditionallyHidden(model); + const [isConditionallyHidden] = useIsConditionallyHidden(model.state.conditionalRendering); const isClone = isRepeatCloneOrChildOf(model); const soloPanelContext = useSoloPanelContext(); @@ -116,7 +116,9 @@ interface TabItemLayoutRendererProps { export function TabItemLayoutRenderer({ tab, isEditing }: TabItemLayoutRendererProps) { const { layout, key } = tab.useState(); const styles = useStyles2(getStyles); - const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(tab); + const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden( + tab.state.conditionalRendering + ); return ( i const conditionalRendering = row.state.conditionalRendering; conditionalRendering?.clearParent(); + // We need to clear the target since we don't want to point the original row anymore (if it was set) + conditionalRendering?.setTarget(undefined); tabs.push( new TabItem({ From cffca379997ec73b70a0c9fcfc1a83b83de8f3d0 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Thu, 27 Nov 2025 12:30:48 +0000 Subject: [PATCH 04/31] FS: Check session expiration and rotate if needed (#114433) * FS: Check session expiration and rotate if needed * Remove unused return values --- pkg/services/frontend/index.html | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index 2eb4d82dce3..0a589f81a49 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -239,6 +239,30 @@ const CHECK_INTERVAL = 1 * 1000; + function getCookie(name) { + const cookies = document.cookie.split(";").map(c => c.trim()); + + for (const cookie of cookies) { + if (cookie.startsWith(name + "=")) { + return cookie.substring(name.length + 1); + } + } + + return null; + } + + function getSessionExpiration() { + const value = getCookie("grafana_session_expiry") || "0"; + const realExpiresSeconds = parseInt(value, 10); + const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration + const expiration = new Date(expiresSeconds * 1000); + return expiration; + } + + async function rotateSession() { + await fetch('/api/user/auth-tokens/rotate', { method: 'POST' }); + } + /** * Fetches boot data from the server. If it returns undefined, it should be retried later. * Will return a rejected promise on unrecoverable errors. @@ -295,6 +319,19 @@ function loadBootData() { return new Promise((resolve, reject) => { const attemptFetch = async () => { + try { + const sessionExpiration = getSessionExpiration(); + const now = new Date(); + + // If the session has expired, don't continue trying to fetch boot data + if (now >= sessionExpiration) { + await rotateSession(); + } + } catch (error) { + // Just ignore any errors in session rotation. The user can just log in again. + console.warn("Failed to rotate session", error); + } + try { const bootData = await fetchBootData(); From 4c869a21a45411278ff5becfeada3afb7eae3620 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Thu, 27 Nov 2025 13:35:49 +0100 Subject: [PATCH 05/31] feat(unified): data migration integration tests (#114418) * feat: unified storage migrations integration tests * chore: add comment and adjust db path name * chore: refactor test cases into interface --- pkg/services/sqlstore/sqlstore.go | 12 +- .../migrations/folders_dashboards_test.go | 208 +++++++++++++++++ .../unified/migrations/migrator_test.go | 210 ++++++++++++++++++ pkg/tests/apis/helper.go | 38 +++- pkg/tests/testinfra/testinfra.go | 17 +- 5 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 pkg/storage/unified/migrations/folders_dashboards_test.go create mode 100644 pkg/storage/unified/migrations/migrator_test.go diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index d4842bee884..1fb8d06d313 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -581,10 +581,16 @@ func TestMain(m *testing.M) { // nolint:staticcheck testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally - if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil { - return nil, err + skipTruncate := false + if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present { + skipTruncate = strings.ToLower(skip) == "true" + } + if !skipTruncate { + if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil { + return nil, err + } + testSQLStore.engine.ResetSequenceGenerator() } - testSQLStore.engine.ResetSequenceGenerator() if err := testSQLStore.Reset(); err != nil { return nil, err diff --git a/pkg/storage/unified/migrations/folders_dashboards_test.go b/pkg/storage/unified/migrations/folders_dashboards_test.go new file mode 100644 index 00000000000..27b1c0e8ba6 --- /dev/null +++ b/pkg/storage/unified/migrations/folders_dashboards_test.go @@ -0,0 +1,208 @@ +package migrations_test + +import ( + "fmt" + "net/http" + "testing" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration +type foldersAndDashboardsTestCase struct { + parentFolderUID string + childFolderUID string + dashboardUID string + libPanelUID string +} + +// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator +func newFoldersAndDashboardsTestCase() resourceMigratorTestCase { + return &foldersAndDashboardsTestCase{ + parentFolderUID: "parent-folder-uid", + childFolderUID: "child-folder-uid", + dashboardUID: "", // Will be generated during setup + libPanelUID: "", // Will be generated during setup + } +} + +func (tc *foldersAndDashboardsTestCase) name() string { + return "folders-dashboards" +} + +func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource { + return []schema.GroupVersionResource{ + { + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + }, + { + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + }, + } +} + +func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) { + t.Helper() + + // Create parent folder + parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "") + + // Create child folder (nested under parent) + child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID) + + // Create library panel in child folder + tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID) + + // Create dashboard with library panel in child folder + tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel", + tc.libPanelUID, "Test LP in dashboard", child.UID) +} + +func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) { + t.Helper() + + // Build maps of UIDs by resource type + folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID} + dashboardUIDs := []string{tc.dashboardUID} + + expectedFolderCount := 0 + if shouldExist { + expectedFolderCount = len(folderUIDs) + } + orgID := helper.Org1.OrgID + namespace := authlib.OrgNamespaceFormatter(orgID) + + // Verify folders + folderCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + }, + }) + verifyResourceCount(t, folderCli, expectedFolderCount) + for _, uid := range folderUIDs { + verifyResource(t, folderCli, uid, shouldExist) + } + + // Verify dashboards + expectedDashboardCount := 0 + if shouldExist { + expectedDashboardCount = len(dashboardUIDs) + } + dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + }, + }) + verifyResourceCount(t, dashboardCli, expectedDashboardCount) + for _, uid := range dashboardUIDs { + verifyResource(t, dashboardCli, uid, shouldExist) + } +} + +// createTestFolder creates a folder with specified UID and optional parent +func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder { + t.Helper() + + payload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s"`, title, uid) + + if parentUID != "" { + payload += fmt.Sprintf(`, + "parentUid": "%s"`, parentUID) + } + + payload += "}" + + folderCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + + require.NotNil(t, folderCreate.Result) + require.Equal(t, uid, folderCreate.Result.UID) + + return folderCreate.Result +} + +// createTestLibraryPanel creates a library panel in a folder +func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string { + t.Helper() + + libPanelPayload := fmt.Sprintf(`{ + "kind": 1, + "name": "%s", + "folderUid": "%s", + "model": { + "type": "text", + "title": "%s" + } + }`, name, folderUID, name) + + libCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/library-elements", + Body: []byte(libPanelPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, libCreate.Response) + require.Equal(t, http.StatusOK, libCreate.Response.StatusCode) + + libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string) + require.NotEmpty(t, libPanelUID) + + return libPanelUID +} + +// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel +func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string { + t.Helper() + + dashPayload := fmt.Sprintf(`{ + "dashboard": { + "title": "%s", + "panels": [{ + "id": 1, + "libraryPanel": { + "uid": "%s", + "name": "%s" + } + }] + }, + "folderUid": "%s", + "overwrite": false + }`, dashTitle, libPanelUID, libPanelName, folderUID) + + dashCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/dashboards/db", + Body: []byte(dashPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, dashCreate.Response) + require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode) + + dashUID := (*dashCreate.Result)["uid"].(string) + require.NotEmpty(t, dashUID) + return dashUID +} diff --git a/pkg/storage/unified/migrations/migrator_test.go b/pkg/storage/unified/migrations/migrator_test.go new file mode 100644 index 00000000000..e75ed26b9e3 --- /dev/null +++ b/pkg/storage/unified/migrations/migrator_test.go @@ -0,0 +1,210 @@ +package migrations_test + +import ( + "context" + "fmt" + "os" + "testing" + + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// resourceMigratorTestCase defines the interface for testing a resource migrator. +type resourceMigratorTestCase interface { + // name returns the test case name + name() string + // resources returns the GVRs that this migrator handles + resources() []schema.GroupVersionResource + // setup creates test resources in legacy storage (Mode0) + setup(t *testing.T, helper *apis.K8sTestHelper) + // verify checks that resources exist (or don't exist) in unified storage + verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) +} + +// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage. +// The test follows a three-step process: +// Step 1: inserts legacy data (migration disabled at startup) +// Step 2: verifies that the data is not in unified storage +// Step 3: migration runs at startup, and the test verifies that the data is in unified storage +func TestIntegrationMigrations(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + migrationTestCases := []resourceMigratorTestCase{ + newFoldersAndDashboardsTestCase(), + } + + runMigrationTestSuite(t, migrationTestCases) +} + +// runMigrationTestSuite executes the migration test suite for the given test cases +func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) { + if db.IsTestDbSQLite() { + // Share the same SQLite DB file between steps + tmpDir := t.TempDir() + dbPath := tmpDir + "/shared-migration-test-suite.db" + + oldVal := os.Getenv("SQLITE_TEST_DB") + require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath)) + t.Cleanup(func() { + if oldVal == "" { + _ = os.Unsetenv("SQLITE_TEST_DB") + } else { + _ = os.Setenv("SQLITE_TEST_DB", oldVal) + } + }) + t.Logf("Using shared database path: %s", dbPath) + } + + // Store UIDs created by each test case + type testCaseState struct { + tc resourceMigratorTestCase + } + testStates := make([]testCaseState, len(testCases)) + for i, tc := range testCases { + testStates[i].tc = tc + } + + // reuse org users throughout the tests + var org1 *apis.OrgUsers + var orgB *apis.OrgUsers + t.Run("Step 1: Create data in legacy", func(t *testing.T) { + // Enforce Mode0 for all migrated resources + unifiedConfig := make(map[string]setting.UnifiedStorageConfig) + for _, tc := range testCases { + for _, gvr := range tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{ + DualWriterMode: grafanarest.Mode0, + } + } + } + + // Set up test environment with Mode0 (writes only to legacy) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }) + t.Cleanup(helper.Shutdown) + org1 = &helper.Org1 + orgB = &helper.OrgB + + for i := range testStates { + state := &testStates[i] + t.Run(state.tc.name(), func(t *testing.T) { + state.tc.setup(t, helper) + // Verify resources were created in legacy storage + state.tc.verify(t, helper, true) + }) + } + }) + + // Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1 + oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE") + require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true")) + t.Cleanup(func() { + if oldSkipTruncate == "" { + _ = os.Unsetenv("SKIP_DB_TRUNCATE") + } else { + _ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate) + } + }) + + t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) { + // Build unified storage config for Mode5 + unifiedConfig := make(map[string]setting.UnifiedStorageConfig) + for _, tc := range testCases { + for _, gvr := range tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{ + DualWriterMode: grafanarest.Mode5, + } + } + } + + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + for _, state := range testStates { + t.Run(state.tc.name(), func(t *testing.T) { + // Verify resources don't exist in unified storage yet + state.tc.verify(t, helper, false) + }) + } + }) + + t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) { + // Migrations will run automatically at startup and mode 5 is enforced by the config + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + // EnableLog: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: false, // Run migrations at startup + APIServerStorageType: "unified", + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + for _, state := range testStates { + t.Run(state.tc.name(), func(t *testing.T) { + // Verify resources now exist in unified storage after migration + state.tc.verify(t, helper, true) + }) + } + }) +} + +// verifyResourceCount verifies that the expected number of resources exist in K8s storage +func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) { + t.Helper() + + l, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + resources, err := meta.ExtractList(l) + require.NoError(t, err) + require.Equal(t, expectedCount, len(resources)) +} + +// verifyResource verifies that a resource with the given UID exists in K8s storage +func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) { + t.Helper() + + _, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + if shouldExist { + require.NoError(t, err) + } else { + require.Error(t, err) + } +} diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index b69b5e9e1d1..35c349184bf 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -93,7 +93,18 @@ type K8sTestHelper struct { userSvc user.Service } +type K8sTestHelperOpts struct { + testinfra.GrafanaOpts + // If provided, these users will be used instead of creating new ones + Org1Users *OrgUsers + OrgBUsers *OrgUsers +} + func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { + return NewK8sTestHelperWithOpts(t, K8sTestHelperOpts{GrafanaOpts: opts}) +} + +func NewK8sTestHelperWithOpts(t *testing.T, opts K8sTestHelperOpts) *K8sTestHelper { t.Helper() // Use GRPC server when not configured @@ -111,9 +122,12 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { path = opts.DirPath ) if opts.Dir == "" && opts.DirPath == "" { - dir, path = testinfra.CreateGrafDir(t, opts) + dir, path = testinfra.CreateGrafDir(t, opts.GrafanaOpts) + } + listenerAddress, env, testDB := testinfra.StartGrafanaEnvWithDB(t, dir, path) + if !opts.DisableDBCleanup { + t.Cleanup(testDB.Cleanup) } - listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path) c := &K8sTestHelper{ env: *env, @@ -143,8 +157,24 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { _ = c.CreateOrg(Org1) _ = c.CreateOrg(Org2) - c.Org1 = c.createTestUsers(Org1) - c.OrgB = c.createTestUsers(Org2) + if opts.Org1Users != nil { + c.Org1 = *opts.Org1Users + c.Org1.Admin.baseURL = listenerAddress + c.Org1.Editor.baseURL = listenerAddress + c.Org1.Viewer.baseURL = listenerAddress + c.Org1.None.baseURL = listenerAddress + } else { + c.Org1 = c.createTestUsers(Org1) + } + if opts.OrgBUsers != nil { + c.OrgB = *opts.OrgBUsers + c.OrgB.Admin.baseURL = listenerAddress + c.OrgB.Editor.baseURL = listenerAddress + c.OrgB.Viewer.baseURL = listenerAddress + c.OrgB.None.baseURL = listenerAddress + } else { + c.OrgB = c.createTestUsers(Org2) + } c.loadAPIGroups() diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index d95f108a231..25b2d4b177b 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -49,6 +49,12 @@ func StartGrafana(t *testing.T, grafDir, cfgPath string) (string, db.DB) { } func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv) { + addr, env, testDB := StartGrafanaEnvWithDB(t, grafDir, cfgPath) + t.Cleanup(testDB.Cleanup) + return addr, env +} + +func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv, *sqlutil.TestDB) { t.Helper() ctx := context.Background() @@ -93,7 +99,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes // Use proper database type based on the environment variable GRAFANA_TEST_DB in tests testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType()) require.NoError(t, err) - t.Cleanup(testDB.Cleanup) dbCfg := cfg.Raw.Section("database") dbCfg.Key("type").SetValue(testDB.DriverName) @@ -169,7 +174,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes t.Logf("Grafana is listening on %s", addr) - return addr, env + return addr, env, testDB } // CreateGrafDir creates the Grafana directory. @@ -538,6 +543,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { _, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes)) require.NoError(t, err) } + if opts.DisableDataMigrations { + section, err := getOrCreateSection("unified_storage") + require.NoError(t, err) + _, err = section.NewKey("disable_data_migrations", "true") + require.NoError(t, err) + } if opts.PermittedProvisioningPaths != "" { _, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths) require.NoError(t, err) @@ -637,6 +648,8 @@ type GrafanaOpts struct { EnableSCIM bool APIServerRuntimeConfig string DisableControllers bool + DisableDBCleanup bool + DisableDataMigrations bool SecretsManagerEnableDBMigrations bool // Allow creating grafana dir beforehand From f872fd7f2f58c8059556de43076f9c038cf5e7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Thu, 27 Nov 2025 14:06:27 +0100 Subject: [PATCH 06/31] Chore: Update `body-parser` to v2.2.1 (#114539) --- yarn.lock | 74 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/yarn.lock b/yarn.lock index 91be1a1621f..abe69dda8b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13051,19 +13051,19 @@ __metadata: linkType: hard "body-parser@npm:^2.2.0": - version: 2.2.0 - resolution: "body-parser@npm:2.2.0" + version: 2.2.1 + resolution: "body-parser@npm:2.2.1" dependencies: bytes: "npm:^3.1.2" content-type: "npm:^1.0.5" - debug: "npm:^4.4.0" + debug: "npm:^4.4.3" http-errors: "npm:^2.0.0" - iconv-lite: "npm:^0.6.3" + iconv-lite: "npm:^0.7.0" on-finished: "npm:^2.4.1" qs: "npm:^6.14.0" - raw-body: "npm:^3.0.0" - type-is: "npm:^2.0.0" - checksum: 10/e9d844b036bd15970df00a16f373c7ed28e1ef870974a0a1d4d6ef60d70e01087cc20a0dbb2081c49a88e3c08ce1d87caf1e2898c615dffa193f63e8faa8a84e + raw-body: "npm:^3.0.1" + type-is: "npm:^2.0.1" + checksum: 10/cab162d62da03058dec8ff4ebf6bf22922b46bf32bd85e59e7fca78d4962aec97b7a7f913dbc3204bb4aa058df03284463ca4c5cc920bf783e591b8de049ffe0 languageName: node linkType: hard @@ -13280,7 +13280,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2, bytes@npm:^3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 @@ -15642,15 +15642,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.1": - version: 4.4.1 - resolution: "debug@npm:4.4.1" +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad languageName: node linkType: hard @@ -15899,7 +15899,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:^2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: 10/c0c8ff36079ce5ada64f46cc9d6fd47ebcf38241105b6e0c98f412e8ad91f084bcf906ff644cc3a4bd876ca27a62accb8b0fff72ea6ed1a414b89d8506f4a5ca @@ -20092,7 +20092,7 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:2.0.0, http-errors@npm:^2.0.0": +"http-errors@npm:2.0.0": version: 2.0.0 resolution: "http-errors@npm:2.0.0" dependencies: @@ -20105,6 +20105,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:^2.0.0, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10/9fe31bc0edf36566c87048aed1d3d0cbe03552564adc3541626a0613f542d753fbcb13bdfcec0a3a530dbe1714bb566c89d46244616b66bddd26ac413b06a207 + languageName: node + linkType: hard + "http-parser-js@npm:>=0.5.1": version: 0.5.6 resolution: "http-parser-js@npm:0.5.6" @@ -20405,7 +20418,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.7.0": +"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": version: 0.7.0 resolution: "iconv-lite@npm:0.7.0" dependencies: @@ -27881,15 +27894,15 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:^3.0.0": - version: 3.0.0 - resolution: "raw-body@npm:3.0.0" +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" dependencies: - bytes: "npm:3.1.2" - http-errors: "npm:2.0.0" - iconv-lite: "npm:0.6.3" - unpipe: "npm:1.0.0" - checksum: 10/2443429bbb2f9ae5c50d3d2a6c342533dfbde6b3173740b70fa0302b30914ff400c6d31a46b3ceacbe7d0925dc07d4413928278b494b04a65736fc17ca33e30c + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10/4168c82157bd69175d5bd960e59b74e253e237b358213694946a427a6f750a18b8e150f036fed3421b3e83294b071a4e2bb01037a79ccacdac05360c63d3ebba languageName: node linkType: hard @@ -30302,7 +30315,7 @@ __metadata: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: 10/fde1630422502fbbc19e6844346778f99d449986b2f9cdcceb8326730d2f3d9964dbcb03c02aaadaefffecd0f2c063315ebea8b3ad895914bf1afc1747fc172e @@ -31147,13 +31160,20 @@ __metadata: languageName: node linkType: hard -"statuses@npm:2.0.1, statuses@npm:^2.0.1": +"statuses@npm:2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1" checksum: 10/18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb languageName: node linkType: hard +"statuses@npm:^2.0.1, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10/6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "statuses@npm:~1.5.0": version: 1.5.0 resolution: "statuses@npm:1.5.0" @@ -32341,7 +32361,7 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 10/952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -32794,7 +32814,7 @@ __metadata: languageName: node linkType: hard -"type-is@npm:^2.0.0, type-is@npm:^2.0.1": +"type-is@npm:^2.0.1": version: 2.0.1 resolution: "type-is@npm:2.0.1" dependencies: From 8515bcc6b077c067f797a03d638b4d521aa53624 Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 27 Nov 2025 14:57:54 +0100 Subject: [PATCH 07/31] Alerting: Use data source headers when remote writing (#114528) --- .../fakes/fake_datasource_service.go | 5 +- .../ngalert/writer/datasourcewriter.go | 12 ++++ .../ngalert/writer/datasourcewriter_test.go | 64 ++++++++++++++++++- pkg/services/ngalert/writer/testing.go | 3 +- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/pkg/services/datasources/fakes/fake_datasource_service.go b/pkg/services/datasources/fakes/fake_datasource_service.go index 19e7bbb43a5..7637657f7cf 100644 --- a/pkg/services/datasources/fakes/fake_datasource_service.go +++ b/pkg/services/datasources/fakes/fake_datasource_service.go @@ -15,6 +15,9 @@ type FakeDataSourceService struct { lastID int64 DataSources []*datasources.DataSource SimulatePluginFailure bool + + // UID -> Headers + DataSourceHeaders map[string]http.Header } var _ datasources.DataSourceService = &FakeDataSourceService{} @@ -152,5 +155,5 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas } func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) { - return nil, nil + return s.DataSourceHeaders[ds.UID], nil } diff --git a/pkg/services/ngalert/writer/datasourcewriter.go b/pkg/services/ngalert/writer/datasourcewriter.go index 19af0db0a5b..cc7036974e7 100644 --- a/pkg/services/ngalert/writer/datasourcewriter.go +++ b/pkg/services/ngalert/writer/datasourcewriter.go @@ -205,11 +205,23 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st return nil, err } + // We need to add the writer headers (valid for any data source) and any data-source-specific headers. headers := make(http.Header) for k, v := range w.cfg.CustomHeaders { headers.Add(k, v) } + dsHeaders, err := w.datasources.CustomHeaders(ctx, ds) + if err != nil { + return nil, fmt.Errorf("failed to get headers for data source: %w", err) + } + + for k, values := range dsHeaders { + for _, v := range values { + headers.Add(k, v) + } + } + var backend backendType if dsUID == string(grafanaCloudPromType) { backend = grafanaCloudPromType diff --git a/pkg/services/ngalert/writer/datasourcewriter_test.go b/pkg/services/ngalert/writer/datasourcewriter_test.go index f06d2d95de4..349d8b9191a 100644 --- a/pkg/services/ngalert/writer/datasourcewriter_test.go +++ b/pkg/services/ngalert/writer/datasourcewriter_test.go @@ -56,13 +56,14 @@ func (m *mockHTTPClientProvider) New(options ...sdkhttpclient.Options) (*http.Cl type testDataSources struct { dsfakes.FakeDataSourceService - prom1, prom2, prom3 *TestRemoteWriteTarget + prom1, prom2, prom3, prom4 *TestRemoteWriteTarget } func (t *testDataSources) Reset() { t.prom1.Reset() t.prom2.Reset() t.prom3.Reset() + t.prom4.Reset() } func setupDataSources(t *testing.T) *testDataSources { @@ -70,7 +71,9 @@ func setupDataSources(t *testing.T) *testDataSources { prom1: NewTestRemoteWriteTarget(t), prom2: NewTestRemoteWriteTarget(t), prom3: NewTestRemoteWriteTarget(t), + prom4: NewTestRemoteWriteTarget(t), } + res.DataSourceHeaders = make(map[string]http.Header) t.Cleanup(func() { res.prom1.Close() @@ -81,6 +84,9 @@ func setupDataSources(t *testing.T) *testDataSources { t.Cleanup(func() { res.prom3.Close() }) + t.Cleanup(func() { + res.prom4.Close() + }) p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ Name: "prom-1", @@ -107,7 +113,7 @@ func setupDataSources(t *testing.T) *testDataSources { Type: datasources.DS_LOKI, }) - // Add a third Prometheus datasource that uses PDC + // Add a third Prometheus datasource that uses PDC. p3, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ Name: "prom-3", UID: "prom-3", @@ -123,6 +129,21 @@ func setupDataSources(t *testing.T) *testDataSources { require.True(t, p3.IsSecureSocksDSProxyEnabled()) + // Add a fourth Prometheus datasource with headers in the JSON config. + p4, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ + Name: "prom-4", + UID: "prom-4", + Type: datasources.DS_PROMETHEUS, + JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)), + }) + p4.URL = res.prom4.srv.URL + res.prom4.ExpectedPath = "/api/v1/write" + res.DataSourceHeaders["prom-4"] = http.Header{ + "X-Scope-OrgID": []string{"test-user"}, + "X-Test-Header": []string{"test-value"}, + "X-Double-Header": []string{"one", "two", "three"}, + } + return res } @@ -204,6 +225,45 @@ func TestDatasourceWriter(t *testing.T) { assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2)) }) + t.Run("when data source headers are configured, they are passed to the request", func(t *testing.T) { + testDS.Reset() + overwrittenHeader := "X-Test-Header" + cHeaders := map[string]string{ + "X-Custom-Header": "test-value", + "X-Another-Header": "another-value", + overwrittenHeader: "overwritten", // Data source headers should be overwritten by custom headers. + } + + cfg = DatasourceWriterConfig{ + Timeout: time.Second * 5, + DefaultDatasourceUID: "prom-1", + CustomHeaders: cHeaders, + } + writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) + + uid := "prom-4" + err := writer.WriteDatasource(context.Background(), uid, "metric", time.Now(), frames, 1, map[string]string{}) + require.NoError(t, err) + + dsHeaders := testDS.DataSourceHeaders[uid] + require.Len(t, dsHeaders, 3) + + // We're confirming we have a data source header with the same name but different value. + // This one should not be sent in the request. + require.NotEmpty(t, dsHeaders[overwrittenHeader]) + require.NotEqual(t, dsHeaders[overwrittenHeader], cHeaders[overwrittenHeader]) + + // All headers (except for the one that was overwritten) should have been used. + for k, vv := range dsHeaders { + if k != overwrittenHeader { + assert.Equal(t, vv, testDS.prom4.LastHeaders.Values(k)) + } + } + for k, v := range cHeaders { + assert.Equal(t, v, testDS.prom4.LastHeaders.Get(k)) + } + }) + t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) { testDS.Reset() diff --git a/pkg/services/ngalert/writer/testing.go b/pkg/services/ngalert/writer/testing.go index 91b1bdb6b10..8b764657d6e 100644 --- a/pkg/services/ngalert/writer/testing.go +++ b/pkg/services/ngalert/writer/testing.go @@ -1,6 +1,7 @@ package writer import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -37,7 +38,7 @@ func NewTestRemoteWriteTarget(t *testing.T) *TestRemoteWriteTarget { handler := func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != target.ExpectedPath { - require.Fail(t, "Received unexpected request for endpoint %s", r.URL.Path) + require.Fail(t, fmt.Sprintf("Received unexpected request for endpoint %s", r.URL.Path)) } target.mtx.Lock() From 80fc87339a6f93f7348a6abc2b7210540bbb1f3d Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 27 Nov 2025 15:11:34 +0100 Subject: [PATCH 08/31] Zanzana: Role binding hooks (#114470) * Zanzana: Role bindings hooks WIP * Empty hooks for role bindings * implement hooks for role bindings * add tests * apply review suggestions --- pkg/registry/apis/iam/register.go | 6 + pkg/registry/apis/iam/role_binding_hooks.go | 302 ++++++++++++ .../apis/iam/role_binding_hooks_test.go | 448 ++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 pkg/registry/apis/iam/role_binding_hooks.go create mode 100644 pkg/registry/apis/iam/role_binding_hooks_test.go diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 99c9dda7d8d..2417c84aed8 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -346,6 +346,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") + roleBindingStore.AfterCreate = b.AfterRoleBindingCreate + roleBindingStore.AfterDelete = b.AfterRoleBindingDelete + roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate + } storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore } //nolint:staticcheck // not yet migrated to OpenFeature diff --git a/pkg/registry/apis/iam/role_binding_hooks.go b/pkg/registry/apis/iam/role_binding_hooks.go new file mode 100644 index 00000000000..c88c46976eb --- /dev/null +++ b/pkg/registry/apis/iam/role_binding_hooks.go @@ -0,0 +1,302 @@ +package iam + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic/registry" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" +) + +const resourceType = "rolebinding" + +// AfterRoleBindingCreate is a post-create hook that writes the role binding to Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if b.zClient == nil { + return + } + + rb, ok := obj.(*iamv0.RoleBinding) + if !ok { + b.logger.Error("failed to convert object to RoleBinding type", "object", obj) + return + } + + operation := "create" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(rb *iamv0.RoleBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("writing role binding to zanzana", + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs)) + for _, roleRef := range rb.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: string(rb.Spec.Subject.Kind), + SubjectName: rb.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + if len(operations) == 0 { + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: rb.Namespace, + Operations: operations, + }) + + if err != nil { + status = "failure" + b.logger.Error("failed to write role binding to zanzana", + "err", err, + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + } + }(rb.DeepCopy()) // Pass a copy of the object +} + +// AfterRoleBindingDelete is a post-delete hook that removes the role binding from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + rb, ok := obj.(*iamv0.RoleBinding) + if !ok { + b.logger.Error("failed to convert object to RoleBinding type", "object", obj) + return + } + + operation := "delete" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(rb *iamv0.RoleBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("deleting role binding from zanzana", + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs)) + for _, roleRef := range rb.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: string(rb.Spec.Subject.Kind), + SubjectName: rb.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + if len(operations) == 0 { + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: rb.Namespace, + Operations: operations, + }) + + if err != nil { + status = "failure" + b.logger.Error("failed to delete role binding from zanzana", + "err", err, + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + } + }(rb.DeepCopy()) // Pass a copy of the object +} + +// BeginRoleBindingUpdate is a pre-update hook that prepares zanzana updates. +// It performs the zanzana write after K8s update succeeds. +func (b *IdentityAccessManagementAPIBuilder) BeginRoleBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + // Extract role bindings from both old and new objects + oldRB, ok := oldObj.(*iamv0.RoleBinding) + if !ok { + return nil, nil + } + + newRB, ok := obj.(*iamv0.RoleBinding) + if !ok { + return nil, nil + } + + if oldRB.Spec.Subject.Name == newRB.Spec.Subject.Name && roleRefsEqual(oldRB.Spec.RoleRefs, newRB.Spec.RoleRefs) { + return nil, nil // No changes to the role binding + } + + if newRB.Spec.Subject.Name == "" { + b.logger.Error("invalid role binding", + "namespace", newRB.Namespace, + "name", newRB.Name, + "subject", newRB.Spec.Subject.Name, + "roleRefs", newRB.Spec.RoleRefs, + ) + return nil, nil + } + + // Return a finish function that performs the zanzana write only on success + return func(ctx context.Context, success bool) { + if !success { + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, "update").Observe(time.Since(wait).Seconds()) + + go func() { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, "update", status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("updating role binding in zanzana", + "namespace", newRB.Namespace, + "name", newRB.Name, + "oldSubject", oldRB.Spec.Subject.Name, + "newSubject", newRB.Spec.Subject.Name, + "oldRoleRefs", oldRB.Spec.RoleRefs, + "newRoleRefs", newRB.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(oldRB.Spec.RoleRefs)) + for _, roleRef := range oldRB.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: string(oldRB.Spec.Subject.Kind), + SubjectName: oldRB.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + for _, roleRef := range newRB.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: string(newRB.Spec.Subject.Kind), + SubjectName: newRB.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + // Only make the request if there are deletes or writes + if len(operations) == 0 { + b.logger.Debug("no role bindings to update in zanzana", "namespace", newRB.Namespace, "name", newRB.Name) + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: newRB.Namespace, + Operations: operations, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to update role binding in zanzana", + "err", err, + "namespace", newRB.Namespace, + "name", newRB.Name, + ) + } + }() + }, nil +} + +func roleRefsEqual(oldRoleRefs, newRoleRefs []iamv0.RoleBindingspecRoleRef) bool { + if len(oldRoleRefs) != len(newRoleRefs) { + return false + } + + oldRoleRefsMap := make(map[string]string) + for _, roleRef := range oldRoleRefs { + oldRoleRefsMap[roleRef.Name] = string(roleRef.Kind) + } + for _, roleRef := range newRoleRefs { + refKind, ok := oldRoleRefsMap[roleRef.Name] + if !ok { + return false + } + if refKind != string(roleRef.Kind) { + return false + } + } + return true +} diff --git a/pkg/registry/apis/iam/role_binding_hooks_test.go b/pkg/registry/apis/iam/role_binding_hooks_test.go new file mode 100644 index 00000000000..dd9646282fe --- /dev/null +++ b/pkg/registry/apis/iam/role_binding_hooks_test.go @@ -0,0 +1,448 @@ +package iam + +import ( + "context" + "slices" + "sync" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/require" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" +) + +func TestAfterRoleBindingCreate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should create zanzana entry for role binding", func(t *testing.T) { + wg.Add(1) + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + testRoleBinding := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) + require.Equal(t, "org-1", req.Namespace) + + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-1", + }, + }, + } + + actualCreate := req.Operations[0].Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding + expectedCreate := expectedOperation.Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding + + require.Equal(t, expectedCreate.SubjectKind, actualCreate.SubjectKind) + require.Equal(t, expectedCreate.SubjectName, actualCreate.SubjectName) + require.Equal(t, expectedCreate.RoleKind, actualCreate.RoleKind) + require.Equal(t, expectedCreate.RoleName, actualCreate.RoleName) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBinding} + b.AfterRoleBindingCreate(&roleBinding, nil) + wg.Wait() + }) + + t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-3", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-3", + }, + }, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterRoleBindingCreate(&roleBinding, nil) + }) +} + +func TestBeginRoleBindingUpdate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entry when role binding changed", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-foo", + }, + { + Kind: "role", + Name: "role-2", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-bar", + }, + }, + }, + } + + testRoleBindingUpdate := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 3) + + // Should write new binding and delete old one + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-foo", + }, + }, + })) + + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-bar", + }, + }, + })) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingUpdate} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should return nil finish func when bindings are identical", func(t *testing.T) { + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + writeCalled := false + testNoWriteOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when bindings are identical") + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnNoChange} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when bindings are identical + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when bindings are identical") + }) + + t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) { + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "", + Name: "", // Empty name - should cause early return + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + writeCalled := false + testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when new binding has empty subject name") + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnInvalidBinding} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name") + }) +} + +func TestAfterRoleBindingDelete(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) { + wg.Add(1) + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + { + Kind: "role", + Name: "role-2", + }, + }, + }, + } + + testRoleBindingDelete := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-1", + }, + }, + })) + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-2", + }, + }, + })) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingDelete} + b.AfterRoleBindingDelete(&roleBinding, nil) + wg.Wait() + }) + + t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-3", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-3", + }, + }, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterRoleBindingDelete(&roleBinding, nil) + }) +} + +func containsOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool { + return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool { + switch operation.Operation.(type) { + case *v1.MutateOperation_DeleteRoleBinding: + deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteRoleBinding) + deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteRoleBinding) + if !ok { + return false + } + return deleteO.DeleteRoleBinding.SubjectKind == deleteOperation.DeleteRoleBinding.SubjectKind && + deleteO.DeleteRoleBinding.SubjectName == deleteOperation.DeleteRoleBinding.SubjectName && + deleteO.DeleteRoleBinding.RoleKind == deleteOperation.DeleteRoleBinding.RoleKind && + deleteO.DeleteRoleBinding.RoleName == deleteOperation.DeleteRoleBinding.RoleName + case *v1.MutateOperation_CreateRoleBinding: + createOperation := operation.Operation.(*v1.MutateOperation_CreateRoleBinding) + createO, ok := o.Operation.(*v1.MutateOperation_CreateRoleBinding) + if !ok { + return false + } + return createO.CreateRoleBinding.SubjectKind == createOperation.CreateRoleBinding.SubjectKind && + createO.CreateRoleBinding.SubjectName == createOperation.CreateRoleBinding.SubjectName && + createO.CreateRoleBinding.RoleKind == createOperation.CreateRoleBinding.RoleKind && + createO.CreateRoleBinding.RoleName == createOperation.CreateRoleBinding.RoleName + } + return false + }) +} From 8e73cc2f70609fb99b6fe65104f742666fc260e8 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 27 Nov 2025 15:14:42 +0100 Subject: [PATCH 09/31] Dashboards: Cover the Switch variable in schema transformations - part 1. (#114293) fix: cover the switch variable when transforming betwen v1 and v2 schemas --- .../src/schema/dashboard/v2_examples.ts | 13 +++++ .../transformSaveModelSchemaV2ToScene.test.ts | 12 ++++- .../api/ResponseTransformers.test.ts | 45 +++++++++++++++++ .../dashboard/api/ResponseTransformers.ts | 49 +++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index 651d858e799..649546e17e1 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -490,5 +490,18 @@ export const handyTestingSchema: Spec = { allowCustomValue: true, }, }, + { + kind: 'SwitchVariable', + spec: { + name: 'switchVar', + label: 'Switch Variable', + description: 'A switch variable', + current: 'false', + enabledValue: 'true', + disabledValue: 'false', + hide: 'dontHide', + skipUrlSync: false, + }, + }, ], }; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 87391044813..f054a9f7cab 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -14,6 +14,7 @@ import { AdHocFiltersVariable, SceneDataTransformer, SceneGridItem, + SwitchVariable, } from '@grafana/scenes'; import { AdhocVariableKind, @@ -27,6 +28,7 @@ import { GroupByVariableKind, IntervalVariableKind, QueryVariableKind, + SwitchVariableKind, TextVariableKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; @@ -204,6 +206,14 @@ describe('transformSaveModelSchemaV2ToScene', () => { sceneVariableClass: AdHocFiltersVariable, index: 7, }); + validateVariable({ + sceneVariable: variables?.state.variables[8], + variableKind: dash.variables[8] as SwitchVariableKind, + scene: scene, + dashSpec: dash, + sceneVariableClass: SwitchVariable, + index: 8, + }); // Annotations expect(scene.state.$data).toBeInstanceOf(DashboardDataLayerSet); @@ -371,7 +381,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { const scene = transformSaveModelSchemaV2ToScene(snapshot); // check variables were converted to snapshot variables - expect(scene.state.$variables?.state.variables).toHaveLength(8); + expect(scene.state.$variables?.state.variables).toHaveLength(9); expect(scene.state.$variables?.getByName('customVar')).toBeInstanceOf(SnapshotVariable); expect(scene.state.$variables?.getByName('adhocVar')).toBeInstanceOf(AdHocFiltersVariable); expect(scene.state.$variables?.getByName('intervalVar')).toBeInstanceOf(SnapshotVariable); diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts index 5ce3e2ffadd..642729d9ed5 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -311,6 +311,31 @@ describe('ResponseTransformers', () => { type: 'query', query: { refId: 'A', query: 'label_values(grafanacloud_org_info{org_slug="$org_slug"}, org_id)' }, }, + { + type: 'switch', + name: 'var9', + label: 'Switch variable', + description: 'Switch variable description', + skipUrlSync: false, + hide: 0, + current: { + value: 'true', + text: 'true', + }, + options: [ + { + selected: true, + text: 'true', + value: 'true', + }, + { + selected: false, + text: 'false', + value: 'false', + }, + ], + query: '', + }, ], }, panels: [ @@ -523,6 +548,7 @@ describe('ResponseTransformers', () => { validateVariablesV1ToV2(spec.variables[6], dashboardV1.templating?.list?.[6]); validateVariablesV1ToV2(spec.variables[7], dashboardV1.templating?.list?.[7]); validateVariablesV1ToV2(spec.variables[8], dashboardV1.templating?.list?.[8]); + validateVariablesV1ToV2(spec.variables[9], dashboardV1.templating?.list?.[9]); }); }); @@ -930,6 +956,7 @@ describe('ResponseTransformers', () => { validateVariablesV1ToV2(dashboardV2.spec.variables[5], dashboard.templating?.list?.[5]); validateVariablesV1ToV2(dashboardV2.spec.variables[6], dashboard.templating?.list?.[6]); validateVariablesV1ToV2(dashboardV2.spec.variables[7], dashboard.templating?.list?.[7]); + validateVariablesV1ToV2(dashboardV2.spec.variables[8], dashboard.templating?.list?.[8]); // annotations validateAnnotation(dashboard.annotations!.list![0], dashboardV2.spec.annotations[0]); validateAnnotation(dashboard.annotations!.list![1], dashboardV2.spec.annotations[1]); @@ -1172,5 +1199,23 @@ describe('ResponseTransformers', () => { expect(v2.group).toEqual(v1.datasource?.type); expect(v2.spec.options).toEqual(v1.options); } + + if (v2.kind === 'SwitchVariable') { + // V1 switch variables have options array with exactly 2 options + // First option is enabledValue, second is disabledValue + const options = v1.options ?? []; + const enabledValueRaw = options[0]?.value ?? 'true'; + const disabledValueRaw = options[1]?.value ?? 'false'; + const enabledValue = Array.isArray(enabledValueRaw) ? enabledValueRaw[0] : enabledValueRaw; + const disabledValue = Array.isArray(disabledValueRaw) ? disabledValueRaw[0] : disabledValueRaw; + + // Current value should be a string (not array) + const currentValueRaw = v1.current?.value ?? disabledValue; + const currentValue = Array.isArray(currentValueRaw) ? currentValueRaw[0] : currentValueRaw; + + expect(v2.spec.current).toBe(currentValue); + expect(v2.spec.enabledValue).toBe(enabledValue); + expect(v2.spec.disabledValue).toBe(disabledValue); + } } }); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index ca8a48de32d..d4bce12f211 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -34,6 +34,7 @@ import { IntervalVariableKind, TextVariableKind, GroupByVariableKind, + SwitchVariableKind, LibraryPanelKind, PanelKind, GridLayoutItemKind, @@ -809,6 +810,29 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables'] variables.push(gb); break; + case 'switch': + // V1 switch variables have options array with exactly 2 options + // First option is typically enabledValue, second is disabledValue + const options = v.options ?? []; + const enabledValueRaw = options[0]?.value ?? 'true'; + const disabledValueRaw = options[1]?.value ?? 'false'; + const enabledValue = Array.isArray(enabledValueRaw) ? enabledValueRaw[0] : enabledValueRaw; + const disabledValue = Array.isArray(disabledValueRaw) ? disabledValueRaw[0] : disabledValueRaw; + // Current value should be a string (not array) + const currentValueRaw = v.current?.value ?? disabledValue; + const currentValue = Array.isArray(currentValueRaw) ? currentValueRaw[0] : currentValueRaw; + + const sw: SwitchVariableKind = { + kind: 'SwitchVariable', + spec: { + ...commonProperties, + current: currentValue, + enabledValue, + disabledValue, + }, + }; + variables.push(sw); + break; default: // do not throw error, just log it console.error(`Variable transformation not implemented: ${v.type}`); @@ -997,6 +1021,29 @@ function getVariablesV1(vars: DashboardV2Spec['variables']): VariableModel[] { }; variables.push(av); break; + case 'SwitchVariable': + const sv: VariableModel = { + ...commonProperties, + current: { + text: v.spec.current, + value: v.spec.current, + }, + options: [ + { + text: v.spec.enabledValue, + value: v.spec.enabledValue, + selected: v.spec.current === v.spec.enabledValue, + }, + { + text: v.spec.disabledValue, + value: v.spec.disabledValue, + selected: v.spec.current === v.spec.disabledValue, + }, + ], + query: '', + }; + variables.push(sv); + break; default: // do not throw error, just log it console.error(`Variable transformation not implemented: ${v}`); @@ -1256,6 +1303,8 @@ function transformToV1VariableTypes(variable: TypedVariableModelV2): VariableTyp return 'groupby'; case 'AdhocVariable': return 'adhoc'; + case 'SwitchVariable': + return 'switch'; default: throw new Error(`Unknown variable type: ${variable}`); } From 42d3673d048542b444e9c2ea55f080f6b073d29e Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Thu, 27 Nov 2025 15:19:38 +0100 Subject: [PATCH 10/31] Alerting: Add rule_limits to rule list requests (#114176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Alerting: Add rule_limits to rule list requests * Unify pagination limits calculation for GMA and DMA rules * Fix limits, add tests * Alerting: Rename filter functions and limit properties for clarity - hasClientSideFilters → hasGrafanaClientSideFilters - hasDatasourceFilters → hasDatasourceClientSideFilters - gmaLimit → grafanaManagedLimit - dmaLimit → datasourceManagedLimit --------- Co-authored-by: Konrad Lalik --- .../alerting/unified/api/prometheusApi.ts | 3 + .../alerting/unified/rule-list/FilterView.tsx | 8 +- .../rule-list/PaginatedGrafanaLoader.tsx | 21 +- .../rule-list/hooks/datasourceFilter.ts | 21 ++ .../rule-list/hooks/filterNormalization.ts | 6 +- .../rule-list/hooks/grafanaFilter.test.ts | 100 +++++----- .../unified/rule-list/hooks/grafanaFilter.ts | 28 +-- .../hooks/prometheusGroupsGenerator.ts | 52 +++-- .../hooks/useFilteredRulesIterator.ts | 12 +- .../rule-list/paginationLimits.test.ts | 181 ++++++++++++++++++ .../unified/rule-list/paginationLimits.ts | 33 ++++ 11 files changed, 362 insertions(+), 103 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-list/paginationLimits.test.ts diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 565a50e46ad..a8e4279de7f 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -39,6 +39,7 @@ export type GrafanaPromRulesOptions = Omit { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts index e6aa3089e55..b2360913581 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts @@ -22,6 +22,27 @@ import { ruleTypeFilter, } from './filterPredicates'; +/** + * Determines if client-side filtering is needed for data source-managed rules. + */ +export function hasDatasourceClientSideFilters(filterState: Partial): boolean { + // Check if any filter that applies to datasource rules is active + return ( + (filterState.freeFormWords && filterState.freeFormWords.length > 0) || + Boolean(filterState.ruleName) || + Boolean(filterState.ruleState) || + Boolean(filterState.ruleType) || + (filterState.dataSourceNames && filterState.dataSourceNames.length > 0) || + (filterState.labels && filterState.labels.length > 0) || + Boolean(filterState.ruleHealth) || + Boolean(filterState.dashboardUid) || + Boolean(filterState.plugins) || + Boolean(filterState.contactPoint) || + Boolean(filterState.namespace) || + Boolean(filterState.groupName) + ); +} + /** * Builds filter configurations for data source-managed alert rules. * diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts index d8594135463..7bcf22e30dc 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts @@ -32,12 +32,14 @@ export function buildTitleSearch(filterState: RulesFilter): string | undefined { * Normalize filter state for case-insensitive matching * Lowercase free form words, rule name, group name and namespace */ -export function normalizeFilterState(filterState: RulesFilter): RulesFilter { +export function normalizeFilterState(filterState: Partial): RulesFilter { return { ...filterState, - freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()), + freeFormWords: filterState.freeFormWords?.map((word) => word.toLowerCase()) ?? [], ruleName: filterState.ruleName?.toLowerCase(), groupName: filterState.groupName?.toLowerCase(), namespace: filterState.namespace?.toLowerCase(), + dataSourceNames: filterState.dataSourceNames ?? [], + labels: filterState.labels ?? [], }; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index 56be25ee248..6a714382a96 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -8,7 +8,7 @@ import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; -import { getGrafanaFilter, hasClientSideFilters } from './grafanaFilter'; +import { getGrafanaFilter, hasGrafanaClientSideFilters } from './grafanaFilter'; jest.mock('../../utils/datasource'); @@ -670,41 +670,41 @@ describe('grafana-managed rules', () => { }); }); - describe('hasClientSideFilters', () => { + describe('hasGrafanaClientSideFilters', () => { describe('when alertingUIUseBackendFilters is disabled', () => { testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] }); it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); }); it('should return true for title-related filters (freeFormWords, ruleName)', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); }); it('should return true for ruleType filter', () => { - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); }); it('should return true for dashboardUid filter', () => { - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); }); it('should return true for groupName filter', () => { - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); }); it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); @@ -712,36 +712,36 @@ describe('grafana-managed rules', () => { testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); }); it('should return false for title-related filters (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); }); it('should return false for ruleType filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); }); it('should return false for dashboardUid filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); }); it('should return false for groupName filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); }); it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); @@ -750,20 +750,20 @@ describe('grafana-managed rules', () => { it('should return correct values for all filter types', () => { // Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters - expect(hasClientSideFilters(getFilter({}))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); // Should return true for: frontend-handled filters - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); }); @@ -772,20 +772,20 @@ describe('grafana-managed rules', () => { it('should return correct values for all filter types', () => { // Should return false for: empty, all backend-handled filters, and backend-only filters - expect(hasClientSideFilters(getFilter({}))).toBe(false); - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); // Should return true for: always-frontend filters only - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index 5b5ddd81f9b..0cc89ceafcf 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -24,26 +24,26 @@ import { /** * Determines if client-side filtering is needed for Grafana-managed rules. */ -export function hasClientSideFilters(filterState: RulesFilter): boolean { +export function hasGrafanaClientSideFilters(filterState: Partial): boolean { const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); // Check each rule filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering const hasActiveRuleFilters = - (ruleFilterConfig.freeFormWords !== null && filterState.freeFormWords.length > 0) || - (ruleFilterConfig.ruleName !== null && Boolean(filterState.ruleName)) || - (ruleFilterConfig.ruleState !== null && Boolean(filterState.ruleState)) || - (ruleFilterConfig.ruleType !== null && Boolean(filterState.ruleType)) || - (ruleFilterConfig.dataSourceNames !== null && filterState.dataSourceNames.length > 0) || - (ruleFilterConfig.labels !== null && filterState.labels.length > 0) || - (ruleFilterConfig.ruleHealth !== null && Boolean(filterState.ruleHealth)) || - (ruleFilterConfig.dashboardUid !== null && Boolean(filterState.dashboardUid)) || - (ruleFilterConfig.plugins !== null && Boolean(filterState.plugins)) || - (ruleFilterConfig.contactPoint !== null && Boolean(filterState.contactPoint)); + (ruleFilterConfig.freeFormWords !== null && Boolean(filterState?.freeFormWords?.length)) || + (ruleFilterConfig.ruleName !== null && Boolean(filterState?.ruleName)) || + (ruleFilterConfig.ruleState !== null && Boolean(filterState?.ruleState)) || + (ruleFilterConfig.ruleType !== null && Boolean(filterState?.ruleType)) || + (ruleFilterConfig.dataSourceNames !== null && Boolean(filterState?.dataSourceNames?.length)) || + (ruleFilterConfig.labels !== null && Boolean(filterState?.labels?.length)) || + (ruleFilterConfig.ruleHealth !== null && Boolean(filterState?.ruleHealth)) || + (ruleFilterConfig.dashboardUid !== null && Boolean(filterState?.dashboardUid)) || + (ruleFilterConfig.plugins !== null && Boolean(filterState?.plugins)) || + (ruleFilterConfig.contactPoint !== null && Boolean(filterState?.contactPoint)); // Check each group filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering const hasActiveGroupFilters = - (groupFilterConfig.namespace !== null && Boolean(filterState.namespace)) || - (groupFilterConfig.groupName !== null && Boolean(filterState.groupName)); + (groupFilterConfig.namespace !== null && Boolean(filterState?.namespace)) || + (groupFilterConfig.groupName !== null && Boolean(filterState?.groupName)); return hasActiveRuleFilters || hasActiveGroupFilters; } @@ -55,7 +55,7 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { * The backend filter is used for server-side filtering when `shouldUseBackendFilters()` is enabled, * while the frontend filter provides client-side matching functions for rules and groups. */ -export function getGrafanaFilter(filterState: RulesFilter) { +export function getGrafanaFilter(filterState: Partial) { const normalizedFilterState = normalizeFilterState(filterState); const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index db2d5df077d..add1097fa0f 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { MergeExclusive } from 'type-fest'; import { DataSourceRulesSourceIdentifier, RuleHealth } from 'app/types/unified-alerting'; import { PromAlertingRuleState, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; @@ -16,27 +17,23 @@ interface UseGeneratorHookOptions { limitAlerts?: number; } -interface FetchGroupsOptions { - groupLimit?: number; - groupNextToken?: string; -} - export function usePrometheusGroupsGenerator() { const [getGroups] = useLazyGetGroupsQuery(); return useCallback( async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) { - const getRuleSourceGroupsWithCache = async (fetchOptions: FetchGroupsOptions) => { + const getRuleSourceGroupsWithCache = async (fetchOptions: GroupsNextPageOptions) => { const response = await getGroups({ ruleSource: { uid: ruleSource.uid }, notificationOptions: { showErrorAlert: false }, + groupLimit, ...fetchOptions, }).unwrap(); return response; }; - yield* genericGroupsGenerator(getRuleSourceGroupsWithCache, groupLimit); + yield* genericGroupsGenerator(getRuleSourceGroupsWithCache); }, [getGroups] ); @@ -52,8 +49,21 @@ interface GrafanaPromApiFilter { dashboardUid?: string; } -interface GrafanaFetchGroupsOptions extends FetchGroupsOptions { +interface GrafanaFetchGroupsOptions extends GroupsNextPageOptions { filter?: GrafanaPromApiFilter; + groupLimit?: number; + // Limits the number of total rules returned across all groups + // Rounds up to full groups, so the response may contain more rules than the group limit + ruleLimit?: number; +} + +export type GrafanaFetchGroupsLimit = MergeExclusive<{ groupLimit: number }, { ruleLimit: number }>; + +export type DataSourceFetchGroupsLimit = { groupLimit: number }; + +export interface FetchGroupsLimitOptions { + grafanaManagedLimit: GrafanaFetchGroupsLimit; + datasourceManagedLimit: DataSourceFetchGroupsLimit; } export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) { @@ -78,11 +88,16 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = ); return useCallback( - async function* (groupLimit: number, filter?: GrafanaPromApiFilter) { - yield* genericGroupsGenerator( - (fetchOptions) => getGroupsAndProvideCache({ ...fetchOptions, filter }), - groupLimit - ); + async function* (limit: GrafanaFetchGroupsLimit, filter?: GrafanaPromApiFilter) { + const fetchGroups = (fetchOptions: GroupsNextPageOptions) => + getGroupsAndProvideCache({ + ...fetchOptions, + filter, + groupLimit: 'groupLimit' in limit ? limit.groupLimit : undefined, + ruleLimit: 'ruleLimit' in limit ? limit.ruleLimit : undefined, + }); + + yield* genericGroupsGenerator(fetchGroups); }, [getGroupsAndProvideCache] ); @@ -105,21 +120,24 @@ export function toIndividualRuleGroups( })(); } +interface GroupsNextPageOptions { + groupNextToken?: string; +} + // Generator lazily provides groups one by one only when needed // This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources // For unpaginated data sources we fetch everything in one go // For paginated we fetch the next page when needed async function* genericGroupsGenerator( - fetchGroups: (options: FetchGroupsOptions) => Promise>, - groupLimit: number + fetchGroups: (options: GroupsNextPageOptions) => Promise> ) { - let response = await fetchGroups({ groupLimit }); + let response = await fetchGroups({ groupNextToken: undefined }); yield response.data.groups; let lastToken: string | undefined = response.data?.groupNextToken; while (lastToken) { - response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit }); + response = await fetchGroups({ groupNextToken: lastToken }); yield response.data.groups; lastToken = response.data?.groupNextToken; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 407426bb3e1..7c5533beea5 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -26,7 +26,11 @@ import { RulePositionHash, createRulePositionHash } from '../rulePositionHash'; import { getDatasourceFilter } from './datasourceFilter'; import { getGrafanaFilter } from './grafanaFilter'; -import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator'; +import { + FetchGroupsLimitOptions, + useGrafanaGroupsGenerator, + usePrometheusGroupsGenerator, +} from './prometheusGroupsGenerator'; export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin; @@ -74,7 +78,7 @@ export function useFilteredRulesIteratorProvider() { const prometheusGroupsGenerator = usePrometheusGroupsGenerator(); const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ limitAlerts: 0 }); - const getFilteredRulesIterable = (filterState: RulesFilter, groupLimit: number): GetIteratorResult => { + const getFilteredRulesIterable = (filterState: RulesFilter, options: FetchGroupsLimitOptions): GetIteratorResult => { /* this is the abort controller that allows us to stop an AsyncIterable */ const abortController = new AbortController(); @@ -83,7 +87,7 @@ export function useFilteredRulesIteratorProvider() { const { backendFilter, frontendFilter } = getGrafanaFilter(filterState); const grafanaRulesGenerator: AsyncIterableX = from( - grafanaGroupsGenerator(groupLimit, backendFilter) + grafanaGroupsGenerator(options.grafanaManagedLimit, backendFilter) ).pipe( withAbort(abortController.signal), concatMap((groups) => @@ -110,7 +114,7 @@ export function useFilteredRulesIteratorProvider() { const dataSourceGenerators: Array> = externalRulesSourcesToFetchFrom.map( (dataSourceIdentifier) => { const promGroupsGenerator: AsyncIterableX = from( - prometheusGroupsGenerator(dataSourceIdentifier, groupLimit) + prometheusGroupsGenerator(dataSourceIdentifier, options.datasourceManagedLimit.groupLimit) ).pipe( withAbort(abortController.signal), concatMap((groups) => diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts new file mode 100644 index 00000000000..5ef8431aced --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -0,0 +1,181 @@ +import { testWithFeatureToggles } from 'test/test-utils'; + +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { RuleHealth, RulesFilter } from '../search/rulesSearchParser'; +import { getFilter } from '../utils/search'; + +import { + FILTERED_GROUPS_LARGE_API_PAGE_SIZE, + FILTERED_GROUPS_SMALL_API_PAGE_SIZE, + RULE_LIMIT_WITH_BACKEND_FILTERS, + getFilteredRulesLimits, +} from './paginationLimits'; + +describe('paginationLimits', () => { + describe('getFilteredRulesLimits', () => { + describe('when backend filters are disabled', () => { + testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return small limits when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])('should return small grafana limit + large datasource limit for backend-only filter: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + { dashboardUid: 'test-dashboard' }, + { plugins: 'hide' as const }, + { namespace: 'production' }, + { groupName: 'test-group' }, + { namespace: 'production', freeFormWords: ['cpu'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when alertingUIUseBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { groupName: 'test-group' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + { ruleState: PromAlertingRuleState.Firing, namespace: 'production' }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { groupName: 'test-group' }, + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when both backend filter toggles are enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { groupName: 'test-group' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.ts index a8e03ac5a48..a5d359ded7b 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.ts @@ -1,3 +1,10 @@ +import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../featureToggles'; +import { RulesFilter } from '../search/rulesSearchParser'; + +import { hasDatasourceClientSideFilters } from './hooks/datasourceFilter'; +import { hasGrafanaClientSideFilters } from './hooks/grafanaFilter'; +import { FetchGroupsLimitOptions } from './hooks/prometheusGroupsGenerator'; + export const FRONTEND_LIST_PAGE_SIZE = 100; export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000; @@ -6,6 +13,8 @@ export const FILTERED_GROUPS_SMALL_API_PAGE_SIZE = 100; export const DEFAULT_GROUPS_API_PAGE_SIZE = 40; export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE; +export const RULE_LIMIT_WITH_BACKEND_FILTERS = 100; + export function getApiGroupPageSize(hasFilters: boolean) { return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; } @@ -13,3 +22,27 @@ export function getApiGroupPageSize(hasFilters: boolean) { export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) { return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE; } + +export function getFilteredRulesLimits(filterState: RulesFilter): FetchGroupsLimitOptions { + return { + grafanaManagedLimit: getGrafanaFilterLimits(filterState), + datasourceManagedLimit: { + groupLimit: hasDatasourceClientSideFilters(filterState) + ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE + : FILTERED_GROUPS_SMALL_API_PAGE_SIZE, + }, + }; +} + +function getGrafanaFilterLimits(filterState: RulesFilter) { + const backendFiltersEnabled = shouldUseFullyCompatibleBackendFilters() || shouldUseBackendFilters(); + + const frontendFiltersInUse = hasGrafanaClientSideFilters(filterState); + const onlyBackendFiltersInUse = frontendFiltersInUse === false; + + if (backendFiltersEnabled && onlyBackendFiltersInUse) { + return { ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }; + } + + return { groupLimit: getSearchApiGroupPageSize(frontendFiltersInUse) }; +} From eedb613a5ee60d4aae68202f616ed464aaab20e9 Mon Sep 17 00:00:00 2001 From: "Marc M." <146180665+grafakus@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:41:38 +0100 Subject: [PATCH 11/31] Dashboards: Don't store options when saving a dashboard with Query/Custom variables (#114540) --- .../src/schema/dashboard/v2_examples.ts | 13 +------ ...sformSceneToSaveModelSchemaV2.test.ts.snap | 13 +------ .../sceneVariablesSetToVariables.test.ts | 36 ++----------------- .../sceneVariablesSetToVariables.ts | 13 +++---- 4 files changed, 8 insertions(+), 67 deletions(-) diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index 649546e17e1..7c542121a7b 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -312,18 +312,7 @@ export const handyTestingSchema: Spec = { label: 'Custom Variable', multi: true, name: 'customVar', - options: [ - { - selected: true, - text: 'option1', - value: 'option1', - }, - { - selected: false, - text: 'option2', - value: 'option2', - }, - ], + options: [], query: 'option1, option2', skipUrlSync: false, allowCustomValue: true, diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index 6bddc68fb09..add2123cdad 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -194,18 +194,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "label": "Custom Variable", "multi": true, "name": "customVar", - "options": [ - { - "selected": true, - "text": "option1", - "value": "option1", - }, - { - "selected": false, - "text": "option2", - "value": "option2", - }, - ], + "options": [], "query": "option1, option2", "skipUrlSync": false, }, diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 8772646b496..cc49cfadc77 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -371,23 +371,7 @@ describe('sceneVariablesSetToVariables', () => { "label": "test-label", "multi": true, "name": "test", - "options": [ - { - "selected": true, - "text": "test", - "value": "test", - }, - { - "selected": false, - "text": "test1", - "value": "test1", - }, - { - "selected": true, - "text": "test2", - "value": "test2", - }, - ], + "options": [], "query": "test,test1,test2", "type": "custom", } @@ -1161,23 +1145,7 @@ describe('sceneVariablesSetToVariables', () => { "label": "test-label", "multi": true, "name": "test", - "options": [ - { - "selected": true, - "text": "test", - "value": "test", - }, - { - "selected": false, - "text": "test1", - "value": "test1", - }, - { - "selected": true, - "text": "test2", - "value": "test2", - }, - ], + "options": [], "query": "test,test1,test2", "skipUrlSync": false, }, diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index 49b84626f97..fa7c2cc3855 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -66,9 +66,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio if (sceneUtils.isQueryVariable(variable)) { let options: VariableOption[] = []; - // Not sure if we actually have to still support this option given - // that it's not exposed in the UI - if (transformVariableRefreshToEnum(variable.state.refresh) === 'never' || keepQueryOptions) { + if (keepQueryOptions) { options = variableValueOptionsToVariableOptions(variable.state); } variables.push({ @@ -106,7 +104,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio // @ts-expect-error value: variable.state.value, }, - options: variableValueOptionsToVariableOptions(variable.state), + options: [], query: variable.state.query, multi: variable.state.isMulti, allValue: variable.state.allValue, @@ -319,9 +317,7 @@ export function sceneVariablesSetToSchemaV2Variables( // Query variable if (sceneUtils.isQueryVariable(variable)) { - // Not sure if we actually have to still support this option given - // that it's not exposed in the UI - if (transformVariableRefreshToEnum(variable.state.refresh) === 'never' || keepQueryOptions) { + if (keepQueryOptions) { options = variableValueOptionsToVariableOptions(variable.state); } const query = variable.state.query; @@ -385,13 +381,12 @@ export function sceneVariablesSetToSchemaV2Variables( // Custom variable } else if (sceneUtils.isCustomVariable(variable)) { - options = variableValueOptionsToVariableOptions(variable.state); const customVariable: CustomVariableKind = { kind: 'CustomVariable', spec: { ...commonProperties, current: currentVariableOption, - options, + options: [], query: variable.state.query, multi: variable.state.isMulti || false, allValue: variable.state.allValue, From 763067f8e13c167195a344f7c0302d87e1dbdb01 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 27 Nov 2025 07:52:42 -0700 Subject: [PATCH 12/31] Dashboard Schema V2: Force v2 when dashboardNewLayouts or v2DashboardAPI are enabled (#113548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * SchemaV2: Convertion from v1beta1 to v2beta1 * Compare backend-frontend v1 convertion * Compare backend-frontend v1 convertion * Fix fe be diff * Resolve DS issues * Fix ds inconsistecnies * fix legacy string value issues * fix ds test * fix layout issue * update test * Fix tests and issue with defaultConfig * Update output * Fix viz config convertion * wip * Fix v1 to v2 dashboard transformation differences Major fixes implemented: - Backend function names in conversion.go - Backend group field logic for queries, annotations, and vizConfig - Backend datasource resolution with map-based lookup - Backend timezone handling (empty string vs browser) - Backend annotation processing (empty array vs default annotation) - Backend default values (editable, liveNow) - Backend variable processing (definition, defaultKeys, refresh, refId) - Backend panel layout (y position calculations) - Backend VizConfig (Kind and Group fields, default values) - Frontend snapshot issue (annotations not processing) - Frontend datasource references (only when original has valid datasource) Test results: - annotation-conversions: PASSING (0 differences) - dashboard-properties: 3 expected architectural differences - panel-conversions: Multiple expected architectural differences - variable-conversions: 7 expected architectural differences All remaining differences are expected architectural choices between backend persistence optimization and frontend UI consumption optimization. * fix issues with panel and annotation queries with no datasource * definition and regex * Use proper v1beta1 resource when testing * remove misc file * fix ds provider test * fix def ds test in response transformer * fix remaining ResponseTransformers test * timesettings, variable refresh, editable, liveNow, definition * fix transformSceneToSaveModelSchemaV2 test * revert legacyRow changes * fix go lint issues * normalize y coordinates when serializing a row * clean up * update tests * use GetStringValue from schemaversion * fix go lint - cyclomatic complexity * update open api snapshot * add migrated dashboards * fix default panel type when panel type is not provided * revert dash link changes for now * fix * fix nested panel issue and default ref in v1 * apply defaults to nested panels too * update snapshots * fix issues with annotations * matchers, showLegend, annotations * when converting also don't process queries that have only a refId * fix issues with text var * fix dash links * default to collapse: false when serializing * fix: filter refId from variable query specs in backend migration - Add buildDataQueryKindForVariable function to filter refId for variables - Remove default refId "A" in transformSingleQuery - Only include __legacyStringValue for non-empty string queries - Remove refId addition in transformSaveModelSchemaV2ToScene.getDataQueryForVariable - Handle undefined queries gracefully in frontend and backend - Ensure backend matches frontend behavior for query variable serialization * fix: default variable refresh to 'never' to match frontend behavior Change backend default for missing refresh field from 'onDashboardLoad' to 'never' to match frontend defaultVariableRefresh() schema default * fix: only include iconColor in annotations when it exists - Frontend: Use defaultAnnotationQuerySpec().iconColor as fallback to match schema defaults - Backend: Only set iconColor if it exists in v1 input (not using GetStringValue) - Ensures iconColor is only included when present in original dashboard * fix: use schema defaults for annotation enable, hide, and iconColor - Use defaultAnnotationQuerySpec() to get schema defaults instead of hardcoded values - Default enable to false (schema default) to match frontend behavior - Use schema default for iconColor and hide fields - Ensures consistency with frontend which uses defaultAnnotationQuerySpec() defaults * fix: set collapse for hidden-header rows to match first explicit row - When panels appear before the first explicit row, the hidden-header row's collapse should match the first explicit row's collapsed value - Matches frontend behavior where collapse: panel.collapsed uses the next row panel's collapsed value - Ensures consistency between frontend and backend when converting rows layout * fix: handle constant variables with missing query value - Frontend: Fix bug where undefined value was converted to string 'undefined' - Now defaults to empty string when value is undefined: value ? String(value) : '' - Backend: Match frontend fix - default to empty string for text/value when query is missing - Ensures consistency when constant variable query is missing from v1 dashboard * Fix interval variable handling when query is missing - Extract intervals from options when query is missing/empty (matches backend behavior) - Handle undefined/null query in getIntervalsFromQueryString - Handle missing current object/value in getCurrentValueForOldIntervalModel - Update interval variable refresh to use literal 'onTimeRangeChanged' in schema - Use defaultIntervalVariableSpec() for interval variable serialization - Backend: Generate query string from options when query is missing * Fix corrupted dashboard with systemRef override * don't resolve types for template variables in datasource refs on the backend * fix annotation and ds issues * fix range and special mappings * fix datasource var pluginId and regex * add __systemRef to schema * update v15 migration annotation to have a ds type because v2 keeps track of if type is in the initial save model, and if it's not it removes it, but for frontendOuput we are running transformSaveModelToScene which will then assign the type * add migration fields since the backend applies automigrations in collapsed rows * filter out queries in ResponseTransformer that only have refId field * lint * v2: add default query if queries are empty to match v1 behavior * fix single migration test * tracking test should have a defined spec otherwise datasource is removed and won't be tracked * initialize default with default ds ref * wip * Do not assign DS if ds group is empty * cleanup * revert change in setupTests.ts * clean up TODO * query with only refId should not expect to have a group * refactor: extract v0alpha1 to v1beta1 conversion logic into atomic function - Extract ConvertDashboard_V0_to_V1beta1 into v0alpha1_to_v1beta1.go - Extract prepareV0ConversionContext and migrateV0Dashboard helper functions - Standardize v0.go to match v1.go pattern with inline multi-step conversions - Implement Convert_V0_to_V2alpha1 using atomic functions (v0->v1beta1->v2alpha1) - Implement Convert_V0_to_V2beta1 using atomic functions (v0->v1beta1->v2alpha1->v2beta1) - Remove non-atomic v0alpha1_to_v2alpha1.go file * test: add version-specific test files for conversion error handling - Extract v0 conversion tests into v0_test.go - Extract v1 conversion tests into v1_test.go - Add v2 conversion tests in v2_test.go - Ensure all error handling paths in conversion functions are covered - Add tests for Convert_V0_to_V2alpha1 and Convert_V0_to_V2beta1 error paths - Add tests for Convert_V1beta1_to_V2alpha1 and Convert_V1beta1_to_V2beta1 error paths - Add tests for Convert_V2alpha1_to_V2beta1 error handling * Fix tests * Fix linter * Clean up * feat(dashboard): Add automatic data loss detection for dashboard conversions Implements comprehensive data loss detection for all dashboard API version conversions. Components Tracked: • Panels (visualization + library panels) • Queries (data source queries, excludes row panel queries) • Annotations • Links • Variables (template variables) Features: • Automatic detection via withConversionMetrics wrapper (zero code changes) • Error type: 'conversion_data_loss_error' • Logs: panelsLost, queriesLost, annotationsLost, linksLost, variablesLost Bugs Found: • Fixed critical bug: metrics.go was silently swallowing ALL errors (return nil → return err) Testing: • TestDataLossDetectionOnAllInputFiles - runs all conversions with detailed logging • V2→V0/V1 downgrades write output for debugging then skip (not yet implemented) • All tests passing * Run dashboards on schema v2 E2Es * reveret unintended changes * cleanup * Reset active manager correctly according to toggles config * Fix new dashboard being serialized as v1 * Rename toggle --------- Co-authored-by: Ivan Ortega Co-authored-by: Dominik Prokop --- .../dashboard-browse-nested.spec.ts | 3 +- .../dashboards-suite/dashboard-browse.spec.ts | 3 +- .../dashboard-export-image.spec.ts | 3 +- .../dashboard-export-json.spec.ts | 3 +- .../dashboard-keybindings.spec.ts | 3 +- .../dashboard-links-without-slug.spec.ts | 3 +- .../dashboard-live-streaming.spec.ts | 3 +- .../dashboard-public-create.spec.ts | 3 +- .../dashboard-public-templating.spec.ts | 3 +- .../dashboard-share-externally-create.spec.ts | 3 +- .../dashboard-share-internally.spec.ts | 3 +- .../dashboard-share-snapshot-create.spec.ts | 3 +- .../dashboard-templating.spec.ts | 3 +- .../dashboard-time-zone.spec.ts | 3 +- .../dashboard-timepicker.spec.ts | 3 +- .../embedded-dashboard.spec.ts | 3 +- .../general-dashboards.spec.ts | 3 +- .../dashboards-suite/import-dashboard.spec.ts | 3 +- .../load-options-from-url.spec.ts | 3 +- .../new-constant-variable.spec.ts | 3 +- .../new-custom-variable.spec.ts | 3 +- .../new-datasource-variable.spec.ts | 3 +- .../new-interval-variable.spec.ts | 3 +- .../new-query-variable.spec.ts | 3 +- .../new-text-box-variable.spec.ts | 3 +- .../repeating-a-panel-horizontally.spec.ts | 3 +- .../repeating-a-panel-vertically.spec.ts | 3 +- .../repeating-an-empty-row.spec.ts | 3 +- .../set-options-from-ui.spec.ts | 3 +- .../dashboards-suite/snapshot-create.spec.ts | 3 +- ...ting-dashboard-links-and-variables.spec.ts | 3 +- .../textbox-variables.spec.ts | 3 +- .../src/types/featureToggles.gen.ts | 4 +++ pkg/registry/apis/dashboard/register.go | 2 +- pkg/services/featuremgmt/registry.go | 7 +++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 +++ pkg/services/featuremgmt/toggles_gen.json | 30 ++++++++++++++++++- .../pages/DashboardScenePageStateManager.ts | 10 +++++-- .../transformSaveModelToScene.ts | 3 +- public/app/features/dashboard/api/utils.ts | 5 +++- 41 files changed, 123 insertions(+), 39 deletions(-) diff --git a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts index 48a3c00359f..6765d299e53 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @@ -9,7 +9,8 @@ const NUM_NESTED_DASHBOARDS = 60; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts index af0d65adc29..8ae318bcefa 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts index 3b3c6d26fac..a97a04a14b8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @@ -6,7 +6,8 @@ test.use({ featureToggles: { scenes: true, sharingDashboardImage: true, // Enable the export image feature - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts index 18145a6c739..428193ab5fa 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index dd420093696..b0ecf44f9f1 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts index 530c74c2485..0a982e148b5 100644 --- a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts index 07aacd5b3fb..20f455ea3a8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DashboardLiveTest.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts index 5c52138ee08..fd5dc979d81 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts index 82a2670ef9e..c59e323076d 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts index d0a6652ccc6..3398e9aaa35 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts @@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts index 98d394ccc91..26f8b85d13e 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts @@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts index 3ac515e36e6..1a7e03d6243 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts @@ -5,7 +5,8 @@ import { SnapshotCreateResponse } from '../../public/app/features/dashboard/serv test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts index ded0abd61ef..78c35dc5de7 100644 --- a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_UID = 'HYaGDGIMk'; test.use({ timezoneId: 'Pacific/Easter', featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 2d56cd14512..937224290b0 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -7,7 +7,8 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts index f7a5c4b9dec..4ffd65b83a3 100644 --- a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts @@ -16,7 +16,8 @@ test.use({ origins: [], }, featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts index 342b912a48a..f457eddf19e 100644 --- a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts index 3cdd27ed954..99f84cb6d9f 100644 --- a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts +++ b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts index 7ec014c728b..5fdca6954aa 100644 --- a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts index c2f45459431..ca06e31528a 100644 --- a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts index 3e28a6f49bc..fa0b5fc1bfd 100644 --- a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts index cc3bda551d9..c14a952e1d9 100644 --- a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts @@ -52,7 +52,8 @@ async function assertPreviewValues( test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts index 988d79f78ca..cc19e67cda4 100644 --- a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts index 0310f6c3500..d76b5291c42 100644 --- a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts @@ -18,7 +18,8 @@ async function assertPreviewValues( test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts index 212f0ecd018..088f4bd9b12 100644 --- a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts index 5c49254284f..c669dc563c4 100644 --- a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts index 920bc343275..a55f14b8643 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts index 3dc9cfcbcc1..bb188c87e8d 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts index 31a60079444..e31c5792062 100644 --- a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts index 556ec524713..53290345e73 100644 --- a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts index 78c6a6a44c9..123aa7f3279 100644 --- a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts @@ -4,7 +4,8 @@ const DASHBOARD_UID = 'ZqZnVvFZz'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 813815f7f39..1d8fd32ff06 100644 --- a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -4,7 +4,8 @@ const DASHBOARD_UID = 'yBCC3aKGk'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts index c992456a1bc..4fb56ef8b8e 100644 --- a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts @@ -6,7 +6,8 @@ const PAGE_UNDER_TEST = 'AejrN1AMz'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 33750126afa..900f86ba33f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -361,6 +361,10 @@ export interface FeatureToggles { */ dashboardNewLayouts?: boolean; /** + * Use the v2 kubernetes API in the frontend for dashboards + */ + kubernetesDashboardsV2?: boolean; + /** * Enables undo/redo in dynamic dashboards */ dashboardUndoRedo?: boolean; diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index fb42543de33..ad598f62f9f 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -198,7 +198,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, } func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { - if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { + if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) { // If dashboards v2 is enabled, we want to use v2beta1 as the default API version. return []schema.GroupVersion{ dashv2beta1.DashboardResourceInfo.GroupVersion(), diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 37ee3001e99..4fe92fe90d6 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -579,6 +579,13 @@ var ( FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, + { + Name: "kubernetesDashboardsV2", + Description: "Use the v2 kubernetes API in the frontend for dashboards", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaDashboardsSquad, + }, { Name: "dashboardUndoRedo", Description: "Enables undo/redo in dynamic dashboards", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f66bc50bbc7..9caa916283d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -80,6 +80,7 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false +kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1217d1f5a28..89a171a76ba 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -259,6 +259,10 @@ const ( // Enables experimental new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" + // FlagKubernetesDashboardsV2 + // Use the v2 kubernetes API in the frontend for dashboards + FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2" + // FlagPdfTables // Enables generating table data as PDF in reporting FlagPdfTables = "pdfTables" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 46e5eda90a7..aeaf5a408af 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1911,6 +1911,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "kubernetesDashboardsV2", + "resourceVersion": "1764236054307", + "creationTimestamp": "2025-11-27T09:34:14Z" + }, + "spec": { + "description": "Use the v2 kubernetes API in the frontend for dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, { "metadata": { "name": "kubernetesExternalGroupMapping", @@ -3547,6 +3559,22 @@ "expression": "true" } }, + { + "metadata": { + "name": "v2DashboardAPIVersion", + "resourceVersion": "1762457740470", + "creationTimestamp": "2025-11-06T19:22:05Z", + "deletionTimestamp": "2025-11-27T09:34:14Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 19:35:40.470587 +0000 UTC" + } + }, + "spec": { + "description": "Enables the v2 dashboard API version", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, { "metadata": { "name": "vizActionsAuth", @@ -3589,4 +3617,4 @@ } } ] -} \ No newline at end of file +} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index a563c17db65..7f8173f9cd9 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -958,6 +958,10 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan } } +export function shouldForceV2API(): boolean { + return Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts); +} + export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase< DashboardDTO | DashboardWithAccessInfo > { @@ -970,7 +974,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta this.v1Manager = new DashboardScenePageStateManager(initialState); this.v2Manager = new DashboardScenePageStateManagerV2(initialState); - this.activeManager = config.featureToggles.dashboardNewLayouts ? this.v2Manager : this.v1Manager; + this.activeManager = shouldForceV2API() ? this.v2Manager : this.v1Manager; } private async withVersionHandling( @@ -1075,7 +1079,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta public async loadDashboard(options: LoadDashboardOptions): Promise { if (options.route === DashboardRoutes.New) { - const newDashboardVersion = config.featureToggles.dashboardNewLayouts ? 'v2' : 'v1'; + const newDashboardVersion = shouldForceV2API() ? 'v2' : 'v1'; this.setActiveManager(newDashboardVersion); } return this.withVersionHandling((manager) => manager.loadDashboard.call(this, options)); @@ -1089,7 +1093,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta } } public resetActiveManager() { - this.setActiveManager('v1'); + this.activeManager = shouldForceV2API() ? this.v2Manager : this.v1Manager; } } diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 41dcc938222..9194adf6a7b 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -33,6 +33,7 @@ import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior'; +import { shouldForceV2API } from '../pages/DashboardScenePageStateManager'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; @@ -258,7 +259,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel, let annotationLayers: SceneDataLayerProvider[] = []; let alertStatesLayer: AlertStatesDataLayer | undefined; const uid = oldModel.uid; - const serializerVersion = config.featureToggles.dashboardNewLayouts && !oldModel.meta.isSnapshot ? 'v2' : 'v1'; + const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot ? 'v2' : 'v1'; if (oldModel.meta.isSnapshot) { variables = createVariablesForSnapshot(oldModel); diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index b0f582869a1..688218de024 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -16,6 +16,9 @@ export function isV2StoredVersion(version: string | undefined): boolean { export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { const isDashboardSceneEnabled = config.featureToggles.dashboardScene; const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards; + const isV2DashboardAPIVersionEnabled = config.featureToggles.kubernetesDashboardsV2; + const isDashboardNewLayoutsEnabled = config.featureToggles.dashboardNewLayouts; + const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false'; // Force legacy API when dashboard scene is disabled or explicitly forced @@ -32,7 +35,7 @@ export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { if (responseFormat === 'v1') { return 'v1'; } - if (responseFormat === 'v2') { + if (responseFormat === 'v2' || isV2DashboardAPIVersionEnabled || isDashboardNewLayoutsEnabled) { return 'v2'; } return 'unified'; From 8e4be891c507cca4519be61653ba5b31f9983570 Mon Sep 17 00:00:00 2001 From: Daniele Stefano Ferru Date: Thu, 27 Nov 2025 16:06:03 +0100 Subject: [PATCH 13/31] Provisioning: add URL and Path in setting response (#114534) * Provisioning: add URL and Path in setting response * linting * marking fields as non-required --- .../pkg/apis/provisioning/v0alpha1/settings.go | 6 ++++++ .../provisioning/v0alpha1/zz_generated.openapi.go | 14 ++++++++++++++ .../rtkq/provisioning/v0alpha1/endpoints.gen.ts | 4 ++++ pkg/registry/apis/provisioning/routes.go | 4 ++++ .../provisioning.grafana.app-v0alpha1.json | 8 ++++++++ pkg/tests/apis/provisioning/repository_test.go | 13 +++++++++++++ 6 files changed, 49 insertions(+) diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go index ee5d264fd83..3031bde77cb 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go @@ -43,6 +43,12 @@ type RepositoryView struct { // For git, this is the target branch Branch string `json:"branch,omitempty"` + // For git, this is the target URL + URL string `json:"url,omitempty"` + + // For git, this is the target path + Path string `json:"path,omitempty"` + // The supported workflows Workflows []Workflow `json:"workflows"` } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 18c385ce59c..9a4a99d703a 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -1690,6 +1690,20 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa Format: "", }, }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "For git, this is the target URL", + Type: []string{"string"}, + Format: "", + }, + }, + "path": { + SchemaProps: spec.SchemaProps{ + Description: "For git, this is the target path", + Type: []string{"string"}, + Format: "", + }, + }, "workflows": { SchemaProps: spec.SchemaProps{ Description: "The supported workflows", diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index 67bdca12d27..7ef1fc4fc91 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1581,6 +1581,8 @@ export type RepositoryView = { branch?: string; /** The k8s name for this repository */ name: string; + /** For git, this is the target path */ + path?: string; /** When syncing, where values are saved Possible enum values: @@ -1598,6 +1600,8 @@ export type RepositoryView = { - `"gitlab"` - `"local"` */ type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local'; + /** For git, this is the target URL */ + url?: string; /** The supported workflows */ workflows: ('branch' | 'write')[]; }; diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go index 4ffefcdfdbb..494d47d16e7 100644 --- a/pkg/registry/apis/provisioning/routes.go +++ b/pkg/registry/apis/provisioning/routes.go @@ -172,6 +172,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) { for i, val := range all { branch := val.Branch() + url := val.URL() + path := val.Path() settings.Items[i] = provisioning.RepositoryView{ Name: val.Name, @@ -179,6 +181,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) { Type: val.Spec.Type, Target: val.Spec.Sync.Target, Branch: branch, + URL: url, + Path: path, Workflows: val.Spec.Workflows, } } 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 bdfa6ce9490..f99b8f60738 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -4309,6 +4309,10 @@ "type": "string", "default": "" }, + "path": { + "description": "For git, this is the target path", + "type": "string" + }, "target": { "description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)", "type": "string", @@ -4335,6 +4339,10 @@ "local" ] }, + "url": { + "description": "For git, this is the target URL", + "type": "string" + }, "workflows": { "description": "The supported workflows", "type": "array", diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index 273d78c4e99..d7850c52d0a 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -136,6 +136,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { return } + for _, i := range settings.Items { + switch i.Type { + case provisioning.LocalRepositoryType: + assert.Equal(collect, i.Path, helper.ProvisioningPath) + case provisioning.GitHubRepositoryType: + assert.Equal(collect, i.URL, "https://github.com/grafana/grafana-git-sync-demo") + assert.Equal(collect, i.Path, "grafana/") + default: + assert.NotEmpty(collect, i.Path) + assert.NotEmpty(collect, i.URL) + } + } + assert.ElementsMatch(collect, []provisioning.RepositoryType{ provisioning.LocalRepositoryType, provisioning.GitHubRepositoryType, From c7ea3d17cc288b8468012b81bd4d726b454dd858 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:09:33 -0500 Subject: [PATCH 14/31] Docs: Fix alias for next and latest docs (#114547) --- .../query-transform-data/sql-expressions/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md b/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md index ded072448ce..a0ba0e55fef 100644 --- a/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md +++ b/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md @@ -1,6 +1,6 @@ --- aliases: - - ../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/ + - ../../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/ labels: products: - cloud From cd797b678958c35aa063b04b65b48214cfd6e5ec Mon Sep 17 00:00:00 2001 From: Steve Simpson Date: Thu, 27 Nov 2025 16:37:09 +0100 Subject: [PATCH 15/31] Alerting: Refactor api_ruler_history.go to allow code re-use. (#114548) --- pkg/services/ngalert/api/api_ruler_history.go | 98 +++++++++++-------- 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler_history.go b/pkg/services/ngalert/api/api_ruler_history.go index 8e32b776274..51c96b2591f 100644 --- a/pkg/services/ngalert/api/api_ruler_history.go +++ b/pkg/services/ngalert/api/api_ruler_history.go @@ -4,11 +4,14 @@ import ( "context" "fmt" "net/http" + "net/url" + "strconv" "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -24,55 +27,64 @@ type HistorySrv struct { hist Historian } -const labelQueryPrefix = "labels_" - func (srv *HistorySrv) RouteQueryStateHistory(c *contextmodel.ReqContext) response.Response { - from := c.QueryInt64("from") - to := c.QueryInt64("to") - limit := c.QueryInt("limit") - ruleUID := c.Query("ruleUID") - dashUID := c.Query("dashboardUID") - panelID := c.QueryInt64("panelID") - - previous := c.Query("previous") - if previous != "" { - _, err := eval.ParseStateString(previous) - if err != nil { - return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid previous state filter: %w", err), "") - } + query, err := ParseHistoryQuery(c.OrgID, c.SignedInUser, c.Req.URL.Query()) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "") } - current := c.Query("current") - if current != "" { - _, err := eval.ParseStateString(current) - if err != nil { - return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid current state filter: %w", err), "") - } - } - - labels := make(map[string]string) - for k, v := range c.Req.URL.Query() { - if strings.HasPrefix(k, labelQueryPrefix) { - labels[k[len(labelQueryPrefix):]] = v[0] - } - } - - query := models.HistoryQuery{ - RuleUID: ruleUID, - OrgID: c.GetOrgID(), - DashboardUID: dashUID, - PanelID: panelID, - Previous: previous, - Current: current, - SignedInUser: c.SignedInUser, - From: time.Unix(from, 0), - To: time.Unix(to, 0), - Limit: limit, - Labels: labels, - } frame, err := srv.hist.Query(c.Req.Context(), query) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") } return response.JSON(http.StatusOK, frame) } + +const labelQueryPrefix = "labels_" + +// ParseHistoryQuery parses a HistoryQuery from request parameters. +func ParseHistoryQuery(orgID int64, user identity.Requester, query url.Values) (models.HistoryQuery, error) { + from, _ := strconv.ParseInt(query.Get("from"), 10, 64) + to, _ := strconv.ParseInt(query.Get("to"), 10, 64) + limit, _ := strconv.Atoi(query.Get("limit")) + ruleUID := query.Get("ruleUID") + dashUID := query.Get("dashboardUID") + panelID, _ := strconv.ParseInt(query.Get("panelID"), 10, 64) + + previous := query.Get("previous") + if previous != "" { + _, err := eval.ParseStateString(previous) + if err != nil { + return models.HistoryQuery{}, fmt.Errorf("invalid previous state filter: %w", err) + } + } + + current := query.Get("current") + if current != "" { + _, err := eval.ParseStateString(current) + if err != nil { + return models.HistoryQuery{}, fmt.Errorf("invalid current state filter: %w", err) + } + } + + labels := make(map[string]string) + for k, v := range query { + if strings.HasPrefix(k, labelQueryPrefix) { + labels[k[len(labelQueryPrefix):]] = v[0] + } + } + + return models.HistoryQuery{ + RuleUID: ruleUID, + OrgID: orgID, + DashboardUID: dashUID, + PanelID: panelID, + Previous: previous, + Current: current, + SignedInUser: user, + From: time.Unix(from, 0), + To: time.Unix(to, 0), + Limit: limit, + Labels: labels, + }, nil +} From 8daa228083a3643adad8e5d8d3effbdb2fd93f37 Mon Sep 17 00:00:00 2001 From: "Marc M." <146180665+grafakus@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:38:42 +0100 Subject: [PATCH 16/31] MetricFindValue: add missing "properties" field to the TS interface (#114486) --- packages/grafana-data/src/types/datasource.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index e5bf0e10a21..538e7a051e8 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -643,6 +643,7 @@ export interface MetricFindValue { value?: string | number; group?: string; expandable?: boolean; + properties?: Record; } export interface DataSourceGetDrilldownsApplicabilityOptions { From f12cc5411d00601f76a106e97cf9dbd38d2ed0b7 Mon Sep 17 00:00:00 2001 From: antonio <45235678+tonypowa@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:09:04 +0100 Subject: [PATCH 17/31] Docs: Add feature request guide for contributors (#114538) * Docs: Add feature request guide for contributors * prettier * redo self contrib section * all pretty no pity * removed duplicate li --- CONTRIBUTING.md | 2 +- contribute/README.md | 1 + contribute/create-feature-request.md | 160 +++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 contribute/create-feature-request.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c62e18c754e..f11c15a3bef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,7 +110,7 @@ If you believe you've found a security vulnerability, please read our [security ### Suggest features -If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). +If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). To learn how to write an effective feature request, refer to [Create a feature request](contribute/create-feature-request.md). We want to make Grafana accessible to even more people. Submit an [accessibility issue](https://github.com/grafana/grafana/issues/new?template=2-accessibility.md) to help us understand what we can improve. diff --git a/contribute/README.md b/contribute/README.md index b5e8ec1222d..d1855d04a04 100644 --- a/contribute/README.md +++ b/contribute/README.md @@ -5,6 +5,7 @@ We're excited that you're considering making a contribution to the Grafana proje These are some good resources to explore for developers: - [Create a pull request](create-pull-request.md) +- [Create a feature request](create-feature-request.md) - [Developer guide](developer-guide.md) - [Triage issues](triage-issues.md) - [Merge a pull request](merge-pull-request.md) diff --git a/contribute/create-feature-request.md b/contribute/create-feature-request.md new file mode 100644 index 00000000000..5ee47bc66e3 --- /dev/null +++ b/contribute/create-feature-request.md @@ -0,0 +1,160 @@ +# Create a feature request + +Feature requests help us understand what you need from Grafana. This document guides you through writing effective feature requests that help maintainers understand your needs and prioritize improvements. + +## Before you begin + +We're excited to hear your ideas! Before you submit a feature request, consider these resources: + +- Read the [Code of Conduct](../CODE_OF_CONDUCT.md) to understand our community guidelines. +- Search [existing feature requests](https://github.com/grafana/grafana/issues?q=is%3Aissue+is%3Aopen+label%3Atype%2Ffeature-request) to see if someone already suggested something similar. +- Discuss your idea in the [Grafana community forums](https://community.grafana.com/) to refine it and gather feedback. + +## Your first feature request + +When you're ready to submit a feature request, use the [feature request template](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). The template has three sections that help maintainers understand what you need and why. + +Here's an [example of how all three sections work together in an actual feature request](https://github.com/grafana/grafana/issues/105298) from the Grafana community. We'll analyze each section based on this example feature request. + +### Why is this needed + +This section describes the real problem or limitation you're facing. + +Explain what's difficult, inefficient, or impossible with the current implementation. Focus on the problem rather than proposing a solution. This helps maintainers understand your use case and potentially find better solutions. + +**What to include:** + +- The specific problem or pain point you're experiencing +- How the current behavior falls short for your workflow +- Why this matters to you and your work +- A concrete example that clarifies the issue (optional but helpful) + +**What to avoid:** + +- Jumping directly to the solution (save that for the next section) +- Vague statements like "it would be nice if..." +- Assuming maintainers know your context or workflow + +**Example of a strong answer:** + +``` +When using a datasource variable in dashboards and using the "Export" feature in a dashboard, +this will automatically create an input for the datasource(s) being used, but it will also +effectively override the use of the datasource variable in all panels. + +This makes a confusing +experience when importing the dashboard, because users are prompted for an input, but the +selected datasource won't be reflected in the datasource variable, and any changes to the +datasource variable will not have any effect on the dashboard. +``` + +**Example of a weak answer:** + +``` +Dashboard export doesn't work well with variables. +``` + +The first example clearly explains what's broken, why it's confusing, and what the specific consequences are. The second example is too vague and doesn't explain the actual problem. + +### What would you like to be added + +This section describes what you want Grafana to do differently. + +Be specific and concrete about the expected behavior. If you're suggesting a UI change, describe the interaction or include a screenshot or sketch. If it's data or API related, provide an example query or expected output. + +**What to include:** + +- Exactly what behavior you expect +- How the feature should work in practice +- Examples, screenshots, or code snippets that illustrate your idea +- Expected output or results + +**What to avoid:** + +- Vague or abstract descriptions +- Multiple unrelated features in one request (create separate requests instead) +- Implementation details unless they're critical to your request + +**Example of a strong answer:** + +``` +Ideal behavior here would be that when using the export feature, either: + +1. No inputs section is created for datasource types that are used as datasource variables. +2. IF an input is created, it should only be used to replace the currently selected value of + the datasource variable, rather than override the datasource in panels. +``` + +**Example of a weak answer:** + +``` +Fix the dashboard export feature. +``` + +The first example provides clear, actionable options for how the feature should work. The second example is too vague and doesn't specify what the fix should do. + +### Who is this feature for? + +This section describes who benefits from this feature and in what context. + +Help maintainers understand the scope and impact of your request. Be specific about user types, workflows, or scenarios where this feature matters. + +**What to include:** + +- The type of user who needs this (for example, Tempo users, dashboard editors, plugin developers) +- Whether this affects all Grafana users or only those using specific features or data sources +- The workflow or use case this feature improves (optional but helpful) + +**What to avoid:** + +- Saying "everyone" without clarifying who actually needs it +- Being overly narrow if the feature has broader appeal + +**Example of a strong answer:** + +``` +Any Grafana Dashboard users or authors that use datasource variables. +``` + +**Example of a weak answer:** + +``` +Dashboard users. +``` + +The first example identifies the specific users and the feature they use (datasource variables). The second example is too generic and doesn't clarify which users or workflow are affected. + +## Best practices for feature requests + +Follow these guidelines to increase the chances of your feature request being accepted: + +### Keep it focused + +Request one feature at a time. If you have multiple ideas, create separate feature requests for each one. This makes it easier to discuss, prioritize, and implement each feature independently. + +### Research first + +Before submitting, search for similar requests. If you find an existing request that's close to your idea, add your use case and context to that discussion instead of creating a duplicate. + +### Provide context + +The more context you provide, the better maintainers can understand your needs. Include: + +- Your environment or setup (which data sources, plugins, or features you're using) +- Your workflow or process +- Why this matters to you +- Any workarounds you've tried + +### Be open to alternatives + +Maintainers might suggest different approaches to solve your problem. Be open to these alternatives as they might be easier to implement or more maintainable in the long term. + +### Stay engaged + +After submitting your feature request, monitor the discussion. Answer questions from maintainers and provide clarification when needed. This helps move your request forward. + +## Contributing the feature yourself + +If you want to implement the feature yourself, feel free to create a pull request following the [pull request guidelines](create-pull-request.md). + +We welcome community contributions and appreciate your help making Grafana better! From ba58506ffdd78d13cdf4d09ddc51ec0fc3f9eb2b Mon Sep 17 00:00:00 2001 From: Alan Martin <53958929+Alan-eMartin@users.noreply.github.com> Date: Thu, 27 Nov 2025 11:12:26 -0500 Subject: [PATCH 18/31] Notifications: Prevent triggering duplicate notifications (#114497) * fix(notifications): prevent event listener re-registration on route changes * refactor(notifications): rename alert handling functions for clarity * refactor(notifications): simplify alert handling by using spread operator for payloads * refactor(events): address feedback - update LegacyEmitter and LegacyEventHandler interfaces for improved type safety * fix(events): ensure event handlers handle undefined events gracefully in tests * test(notifications): add tests for event listener registration and cleanup in AppNotificationList --- .../grafana-data/src/events/EventBus.test.ts | 10 +++- packages/grafana-data/src/events/types.ts | 4 +- .../AppNotificationList.test.tsx | 43 ++++++++++++++ .../AppNotifications/AppNotificationList.tsx | 57 +++++++++++++++---- 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/packages/grafana-data/src/events/EventBus.test.ts b/packages/grafana-data/src/events/EventBus.test.ts index 6de48b5d54e..3708b47d78b 100644 --- a/packages/grafana-data/src/events/EventBus.test.ts +++ b/packages/grafana-data/src/events/EventBus.test.ts @@ -91,8 +91,10 @@ describe('EventBus', () => { it('Supports legacy events', () => { const bus = new EventBusSrv(); const events: LegacyEventPayload[] = []; - const handler = (event: LegacyEventPayload) => { - events.push(event); + const handler = (event?: LegacyEventPayload) => { + if (event) { + events.push(event); + } }; bus.on(legacyEvent, handler); @@ -111,7 +113,9 @@ describe('EventBus', () => { const newEvents: AlertSuccessEvent[] = []; bus.on(legacyEvent, (event) => { - legacyEvents.push(event); + if (event) { + legacyEvents.push(event); + } }); bus.subscribe(AlertSuccessEvent, (event) => { diff --git a/packages/grafana-data/src/events/types.ts b/packages/grafana-data/src/events/types.ts index 514b980d1b4..ce5499d00ed 100644 --- a/packages/grafana-data/src/events/types.ts +++ b/packages/grafana-data/src/events/types.ts @@ -133,12 +133,12 @@ export interface LegacyEmitter { /** * @deprecated use $on */ - off(event: AppEvent | string, handler: (payload?: T) => void): void; + off(event: AppEvent | string, handler: LegacyEventHandler): void; } /** @public */ export interface LegacyEventHandler { - (payload: T): void; + (payload?: T): void; wrapper?: (event: BusEvent) => void; } diff --git a/public/app/core/components/AppNotifications/AppNotificationList.test.tsx b/public/app/core/components/AppNotifications/AppNotificationList.test.tsx index 61500e0cd4c..3c3eb04a44d 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.test.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.test.tsx @@ -98,6 +98,49 @@ describe('AppNotificationList', () => { }); }); + describe('Event listener cleanup', () => { + let onSpy: jest.SpyInstance; + let offSpy: jest.SpyInstance; + + const eventTypes = [AppEvents.alertWarning, AppEvents.alertSuccess, AppEvents.alertError, AppEvents.alertInfo]; + + beforeEach(() => { + onSpy = jest.spyOn(appEvents, 'on'); + offSpy = jest.spyOn(appEvents, 'off'); + }); + + afterEach(() => { + onSpy.mockRestore(); + offSpy.mockRestore(); + }); + + it('should register event listeners on mount', () => { + renderWithContext(); + + expect(onSpy).toHaveBeenCalledTimes(4); + eventTypes.forEach((eventType) => { + expect(onSpy).toHaveBeenCalledWith(eventType, expect.any(Function)); + }); + }); + + it('should unregister event listeners on unmount', () => { + const { unmount } = renderWithContext(); + + const handlers = eventTypes.map((eventType) => { + const handler = onSpy.mock.calls.find((call) => call[0] === eventType)?.[1]; + expect(handler).toBeDefined(); + return { eventType, handler }; + }); + + unmount(); + + expect(offSpy).toHaveBeenCalledTimes(4); + handlers.forEach(({ eventType, handler }) => { + expect(offSpy).toHaveBeenCalledWith(eventType, handler); + }); + }); + }); + describe('Edge cases', () => { it('should show error on dashboard page with uid and slug', async () => { renderWithContext(undefined, '/d/test-uid/test-slug'); diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 29cc9a4b0e3..8d5a1d3161e 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { AlertErrorPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; +import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Stack } from '@grafana/ui'; import { notifyApp, hideAppNotification } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; @@ -26,25 +26,60 @@ export function AppNotificationList() { const { chrome } = useGrafana(); const location = useLocation(); + // Store location ref to avoid re-registering listeners on route changes + const locationRef = useRef(location); + useEffect(() => { + locationRef.current = location; + }, [location]); + useEffect(() => { // Suppress error notifications in kiosk mode on dashboards. // Kiosk mode is typically used for TV displays which are non-interactive. // Backend errors like "Failed to fetch" cannot be dismissed and would remain visible, // degrading the viewing experience. Other notification types (success, warning, info) // are still shown as they indicate successful operations or important information. - const handleErrorAlert = (payload: AlertErrorPayload) => { - const isKioskDashboard = chrome.state.getValue().kioskMode && location.pathname.startsWith('/d/'); - - if (!isKioskDashboard) { - dispatch(notifyApp(createErrorNotification(...payload))); + const handleErrorAlert = (payload?: AlertErrorPayload) => { + const isKioskDashboard = chrome.state.getValue().kioskMode && locationRef.current.pathname.startsWith('/d/'); + if (isKioskDashboard || !payload) { + return; } + dispatch(notifyApp(createErrorNotification(...payload))); }; - appEvents.on(AppEvents.alertWarning, (payload) => dispatch(notifyApp(createWarningNotification(...payload)))); - appEvents.on(AppEvents.alertSuccess, (payload) => dispatch(notifyApp(createSuccessNotification(...payload)))); + const handleWarningAlert = (payload?: AlertPayload) => { + if (!payload) { + return; + } + dispatch(notifyApp(createWarningNotification(...payload))); + }; + + const handleSuccessAlert = (payload?: AlertPayload) => { + if (!payload) { + return; + } + dispatch(notifyApp(createSuccessNotification(...payload))); + }; + + const handleInfoAlert = (payload?: AlertPayload) => { + if (!payload) { + return; + } + dispatch(notifyApp(createInfoNotification(...payload))); + }; + + appEvents.on(AppEvents.alertWarning, handleWarningAlert); + appEvents.on(AppEvents.alertSuccess, handleSuccessAlert); appEvents.on(AppEvents.alertError, handleErrorAlert); - appEvents.on(AppEvents.alertInfo, (payload) => dispatch(notifyApp(createInfoNotification(...payload)))); - }, [dispatch, chrome, location.pathname]); + appEvents.on(AppEvents.alertInfo, handleInfoAlert); + + return () => { + // Unsubscribe from events on unmount to avoid memory leaks + appEvents.off(AppEvents.alertWarning, handleWarningAlert); + appEvents.off(AppEvents.alertSuccess, handleSuccessAlert); + appEvents.off(AppEvents.alertError, handleErrorAlert); + appEvents.off(AppEvents.alertInfo, handleInfoAlert); + }; + }, [dispatch, chrome]); const onClearAppNotification = (id: string) => { dispatch(hideAppNotification(id)); From 7b8191ba4264216243a759533ca77e422e978b04 Mon Sep 17 00:00:00 2001 From: Steve Simpson Date: Thu, 27 Nov 2025 17:14:16 +0100 Subject: [PATCH 19/31] Alerting: Add kubernetesAlertingHistorian feature toggle. (#114551) --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 15 ++++++++++++++- 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 900f86ba33f..35cccd78375 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1193,4 +1193,8 @@ export interface FeatureToggles { * @default false */ rudderstackUpgrade?: boolean; + /** + * Adds support for Kubernetes alerting historian APIs + */ + kubernetesAlertingHistorian?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4fe92fe90d6..b4b34f578fa 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1970,6 +1970,13 @@ var ( RequiresRestart: false, HideFromDocs: false, }, + { + Name: "kubernetesAlertingHistorian", + Description: "Adds support for Kubernetes alerting historian APIs", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + RequiresRestart: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 9caa916283d..29af9d445f7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -267,3 +267,4 @@ transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true +kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 89a171a76ba..3183c587efd 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -761,4 +761,8 @@ const ( // FlagAwsDatasourcesHttpProxy // Enables http proxy settings for aws datasources FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy" + + // FlagKubernetesAlertingHistorian + // Adds support for Kubernetes alerting historian APIs + FlagKubernetesAlertingHistorian = "kubernetesAlertingHistorian" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index aeaf5a408af..fd29fa544ee 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1790,6 +1790,19 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "kubernetesAlertingHistorian", + "resourceVersion": "1764257713773", + "creationTimestamp": "2025-11-27T15:35:13Z" + }, + "spec": { + "description": "Adds support for Kubernetes alerting historian APIs", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, { "metadata": { "name": "kubernetesAlertingRules", @@ -3617,4 +3630,4 @@ } } ] -} +} \ No newline at end of file From df2f5286121fc9e6cc8fbc56465e4b5f33d2397f Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Thu, 27 Nov 2025 10:29:16 -0600 Subject: [PATCH 20/31] Unified Storage: Adds overrides service to resource server (#113794) * first pass of adding quotas service resource server * passes prom reg as param init quota service as part of server params * init quota service as part of server params * adds config and only creates quota service when overrides file path is defined * when quota service enabled, check quota on create and log result * update log message * adds tests for quota service * adds tests for config reloading when the file changes * fix linter errors * fix comment * use startAndAwaitRunning * Simplifies quotas service. Call manager.GetConfig() when getting quota instead of watching for changes. * adds tracing to quotas service * adds nsr attributes to traces when getting quotas and resource stats * update comment * update comment remove check for nil overrides since it will (should) never happen * fix linter error * refactors naming to overrides service checks quotas in separate function * fix quotas naming * fixes more quotas -> overrides naming * use logger from ctx * linter - remove trailing whitespace * log FromContext() when checking quotas * adds events to spans instead of create new spans updates tenant -> namespace naming few other minor fixes --- pkg/setting/setting.go | 2 + pkg/setting/setting_unified_storage.go | 4 + pkg/storage/unified/client.go | 13 + pkg/storage/unified/resource/quotas.go | 127 ++++++ pkg/storage/unified/resource/quotas_test.go | 427 ++++++++++++++++++++ pkg/storage/unified/resource/server.go | 80 +++- pkg/storage/unified/sql/backend.go | 7 +- pkg/storage/unified/sql/server.go | 28 +- pkg/storage/unified/sql/service.go | 12 + 9 files changed, 673 insertions(+), 27 deletions(-) create mode 100644 pkg/storage/unified/resource/quotas.go create mode 100644 pkg/storage/unified/resource/quotas_test.go diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 470b6910751..f6c0b3d3f19 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -615,6 +615,8 @@ type Cfg struct { HttpsSkipVerify bool ResourceServerJoinRingTimeout time.Duration EnableSearch bool + OverridesFilePath string + OverridesReloadInterval time.Duration // Secrets Management SecretsManagement SecretsManagerSettings diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 29cb5d0270a..3623228fe8b 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -94,6 +94,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.HttpsSkipVerify = section.Key("https_skip_verify").MustBool(false) cfg.ResourceServerJoinRingTimeout = section.Key("resource_server_join_ring_timeout").MustDuration(10 * time.Second) + // quotas/limits config + cfg.OverridesFilePath = section.Key("overrides_path").String() + cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second) + cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0) cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("") } diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index 07165937199..45130c8a6a6 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -211,6 +211,19 @@ func newClient(opts options.StorageOptions, serverOptions.QOSQueue = queue } + // only enable if an overrides file path is provided + if cfg.OverridesFilePath != "" { + overridesSvc, err := resource.NewOverridesService(ctx, cfg.Logger, reg, tracer, resource.ReloadOptions{ + FilePath: cfg.OverridesFilePath, + ReloadPeriod: cfg.OverridesReloadInterval, + }) + if err != nil { + return nil, err + } + + serverOptions.OverridesService = overridesSvc + } + server, err := sql.NewResourceServer(serverOptions) if err != nil { return nil, err diff --git a/pkg/storage/unified/resource/quotas.go b/pkg/storage/unified/resource/quotas.go new file mode 100644 index 00000000000..d956a6da017 --- /dev/null +++ b/pkg/storage/unified/resource/quotas.go @@ -0,0 +1,127 @@ +package resource + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/grafana/dskit/runtimeconfig" + "github.com/grafana/dskit/services" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/trace" + "go.yaml.in/yaml/v3" +) + +const DEFAULT_RESOURCE_LIMIT = 1000 + +type OverridesService struct { + manager *runtimeconfig.Manager + logger log.Logger + tracer trace.Tracer +} + +type ReloadOptions struct { + FilePath string + ReloadPeriod time.Duration +} + +// ResourceQuota represents quota limits for a specific resource +type ResourceQuota struct { + Limit int `yaml:"limit"` +} + +// NamespaceOverrides represents all overrides for a tenant +type NamespaceOverrides struct { + Quotas map[string]ResourceQuota `yaml:"quotas"` +} + +// Overrides represents the entire overrides configuration file +type Overrides struct { + Namespaces map[string]NamespaceOverrides +} + +/* +This service loads overrides (currently just quotas) from a YAML file with the following yaml structure: + +"123": + + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 1500 +*/ +func NewOverridesService(_ context.Context, logger log.Logger, reg prometheus.Registerer, tracer trace.Tracer, opts ReloadOptions) (*OverridesService, error) { + // shouldn't be empty since we use file path existence to determine if we should enable the service + if opts.FilePath == "" { + return nil, fmt.Errorf("overrides file path is required") + } + if opts.ReloadPeriod == 0 { + opts.ReloadPeriod = time.Second * 30 + } + + // Check if file exists + if _, err := os.Stat(opts.FilePath); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("overrides file does not exist: %s", opts.FilePath) + } + return nil, fmt.Errorf("failed to stat overrides file: %w", err) + } + + config := runtimeconfig.Config{ + ReloadPeriod: opts.ReloadPeriod, + LoadPath: []string{opts.FilePath}, + Loader: func(r io.Reader) (interface{}, error) { + var tenants map[string]NamespaceOverrides + decoder := yaml.NewDecoder(r) + if err := decoder.Decode(&tenants); err != nil { + return nil, err + } + return &Overrides{Namespaces: tenants}, nil + }, + } + + manager, err := runtimeconfig.New(config, "tenant-overrides", reg, logger) + if err != nil { + return nil, err + } + + return &OverridesService{ + manager: manager, + logger: logger, + tracer: tracer, + }, nil +} + +func (q *OverridesService) init(ctx context.Context) error { + return services.StartAndAwaitRunning(ctx, q.manager) +} + +func (q *OverridesService) stop(ctx context.Context) error { + return services.StopAndAwaitTerminated(ctx, q.manager) +} + +func (q *OverridesService) GetQuota(_ context.Context, nsr NamespacedResource) (ResourceQuota, error) { + if nsr.Namespace == "" || nsr.Resource == "" || nsr.Group == "" { + return ResourceQuota{}, fmt.Errorf("invalid namespaced resource: %+v", nsr) + } + + overrides, ok := q.manager.GetConfig().(*Overrides) + if !ok { + return ResourceQuota{}, fmt.Errorf("failed to get quota overrides from config manager") + } + + tenantId := strings.TrimPrefix(nsr.Namespace, "stacks-") + groupResource := nsr.Group + "/" + nsr.Resource + if tenantOverrides, ok := overrides.Namespaces[tenantId]; ok { + if resourceQuota, ok := tenantOverrides.Quotas[groupResource]; ok { + return resourceQuota, nil + } + } + + return ResourceQuota{Limit: DEFAULT_RESOURCE_LIMIT}, nil +} diff --git a/pkg/storage/unified/resource/quotas_test.go b/pkg/storage/unified/resource/quotas_test.go new file mode 100644 index 00000000000..97f9f355af6 --- /dev/null +++ b/pkg/storage/unified/resource/quotas_test.go @@ -0,0 +1,427 @@ +package resource + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewQuotaService(t *testing.T) { + tests := []struct { + name string + opts ReloadOptions + setupFile func(t *testing.T) string + expectError bool + errorMsg string + }{ + { + name: "success with valid file", + opts: ReloadOptions{}, + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + expectError: false, + }, + { + name: "success with custom reload period", + opts: ReloadOptions{ + ReloadPeriod: time.Minute, + }, + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + require.NoError(t, os.WriteFile(tmpFile, []byte{}, 0644)) + return tmpFile + }, + expectError: false, + }, + { + name: "error when file path is empty", + opts: ReloadOptions{ + FilePath: "", + }, + setupFile: func(t *testing.T) string { return "" }, + expectError: true, + errorMsg: "overrides file path is required", + }, + { + name: "error when file does not exist", + opts: ReloadOptions{ + FilePath: "/nonexistent/path/overrides.yaml", + }, + setupFile: func(t *testing.T) string { return "/nonexistent/path/overrides.yaml" }, + expectError: true, + errorMsg: "overrides file does not exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + logger := log.NewNopLogger() + reg := prometheus.NewRegistry() + tcr := tracing.NewNoopTracerService() + + filePath := tt.setupFile(t) + if filePath != "" && tt.opts.FilePath == "" { + tt.opts.FilePath = filePath + } + + service, err := NewOverridesService(ctx, logger, reg, tcr, tt.opts) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + assert.Nil(t, service) + } else { + require.NoError(t, err) + assert.NotNil(t, service) + assert.NotNil(t, service.manager) + assert.NotNil(t, service.logger) + } + }) + } +} + +func TestQuotaService_ConfigReload(t *testing.T) { + ctx := context.Background() + logger := log.NewNopLogger() + reg := prometheus.NewRegistry() + tcr := tracing.NewNoopTracerService() + + // Create a temporary config file + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + initialConfig := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(initialConfig), 0644)) + + // Create service with a very short reload period + service, err := NewOverridesService(ctx, logger, reg, tcr, ReloadOptions{ + FilePath: tmpFile, + ReloadPeriod: 100 * time.Millisecond, // Very short reload period for testing + }) + require.NoError(t, err) + require.NotNil(t, service) + + // Initialize the service + err = service.init(ctx) + require.NoError(t, err) + defer func(service *OverridesService, ctx context.Context) { + err := service.stop(ctx) + require.NoError(t, err) + }(service, ctx) + + // Verify initial config + nsr := NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + } + quota, err := service.GetQuota(ctx, nsr) + require.NoError(t, err) + assert.Equal(t, 1500, quota.Limit, "initial quota should be 1500") + + // Update the config file with new values + updatedConfig := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 2500 +"456": + quotas: + grafana.folder.app/folders: + limit: 3000 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(updatedConfig), 0644)) + + // Wait for the config to be reloaded (wait longer than reload period) + time.Sleep(500 * time.Millisecond) + + // Verify the config was updated for existing tenant + quota, err = service.GetQuota(ctx, nsr) + require.NoError(t, err) + assert.Equal(t, 2500, quota.Limit, "quota should be updated to 2500") + + // Verify new tenant config is also loaded + nsr2 := NamespacedResource{ + Namespace: "stacks-456", + Group: "grafana.folder.app", + Resource: "folders", + } + quota2, err := service.GetQuota(ctx, nsr2) + require.NoError(t, err) + assert.Equal(t, 3000, quota2.Limit, "new tenant quota should be 3000") +} + +func TestQuotaService_GetQuota(t *testing.T) { + tests := []struct { + name string + setupFile func(t *testing.T) string + nsr NamespacedResource + expectedLimit int + expectError bool + errorMsg string + description string + }{ + { + name: "returns custom quota for matching tenant and resource", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: 1500, + expectError: false, + description: "should return custom limit for matching tenant", + }, + { + name: "returns default quota when tenant not found", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-456", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: DEFAULT_RESOURCE_LIMIT, + expectError: false, + description: "should return default limit when tenant not found", + }, + { + name: "returns default quota when resource not found", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.folder.app", + Resource: "folders", + }, + expectedLimit: DEFAULT_RESOURCE_LIMIT, + expectError: false, + description: "should return default limit when resource not found", + }, + { + name: "handles namespace without stacks- prefix", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: 1500, + expectError: false, + description: "should handle namespace without stacks- prefix", + }, + { + name: "returns default quota when config is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := "" + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: DEFAULT_RESOURCE_LIMIT, + expectError: false, + description: "should return default limit when config is empty", + }, + { + name: "handles multiple resources for same tenant", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.folder.app", + Resource: "folders", + }, + expectedLimit: 2500, + expectError: false, + description: "should return correct limit for specific resource", + }, + { + name: "returns error when namespace is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when namespace is empty", + }, + { + name: "returns error when group is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "", + Resource: "dashboards", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when group is empty", + }, + { + name: "returns error when resource is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when resource is empty", + }, + { + name: "returns error when all fields are empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "", + Group: "", + Resource: "", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when all fields are empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + logger := log.NewNopLogger() + reg := prometheus.NewRegistry() + tcr := tracing.NewNoopTracerService() + opts := ReloadOptions{ + FilePath: tt.setupFile(t), + } + + service, err := NewOverridesService(ctx, logger, reg, tcr, opts) + require.NoError(t, err, "failed to create quota service") + err = service.init(ctx) + require.NoError(t, err, "failed to initialize quota service") + + quota, err := service.GetQuota(ctx, tt.nsr) + + if tt.expectError { + require.Error(t, err, tt.description) + assert.Contains(t, err.Error(), tt.errorMsg, tt.description) + assert.Equal(t, ResourceQuota{}, quota, "should return empty quota on error") + } else { + require.NoError(t, err, tt.description) + assert.Equal(t, tt.expectedLimit, quota.Limit, tt.description) + } + }) + } +} diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 4c0adddf3c8..dd091c6e2d4 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -14,6 +14,8 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -220,6 +222,9 @@ type ResourceServerOptions struct { // Search options Search SearchOptions + // Quota service + OverridesService *OverridesService + // Diagnostics Diagnostics resourcepb.DiagnosticsServer @@ -342,6 +347,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { reg: opts.Reg, queue: opts.QOSQueue, queueConfig: opts.QOSConfig, + overridesService: opts.OverridesService, artificialSuccessfulWriteDelay: opts.Search.IndexMinUpdateInterval, } @@ -366,19 +372,20 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { var _ ResourceServer = &server{} type server struct { - log log.Logger - backend StorageBackend - blob BlobSupport - secure secrets.InlineSecureValueSupport - search *searchSupport - diagnostics resourcepb.DiagnosticsServer - access claims.AccessClient - writeHooks WriteAccessHooks - lifecycle LifecycleHooks - now func() int64 - mostRecentRV atomic.Int64 // The most recent resource version seen by the server - storageMetrics *StorageMetrics - indexMetrics *BleveIndexMetrics + log log.Logger + backend StorageBackend + blob BlobSupport + secure secrets.InlineSecureValueSupport + search *searchSupport + diagnostics resourcepb.DiagnosticsServer + access claims.AccessClient + writeHooks WriteAccessHooks + lifecycle LifecycleHooks + now func() int64 + mostRecentRV atomic.Int64 // The most recent resource version seen by the server + storageMetrics *StorageMetrics + indexMetrics *BleveIndexMetrics + overridesService *OverridesService // Background watch task -- this has permissions for everything ctx context.Context @@ -411,6 +418,11 @@ func (s *server) Init(ctx context.Context) error { } } + // initialize tenant overrides service + if s.initErr == nil && s.overridesService != nil { + s.initErr = s.overridesService.init(ctx) + } + // initialize the search index if s.initErr == nil && s.search != nil { s.initErr = s.search.init(ctx) @@ -444,6 +456,13 @@ func (s *server) Stop(ctx context.Context) error { s.search.stop() } + if s.overridesService != nil { + if err := s.overridesService.stop(ctx); err != nil { + stopFailed = true + s.initErr = fmt.Errorf("service stopeed with error: %w", err) + } + } + // Stops the streaming s.cancel() @@ -647,6 +666,13 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re ctx, span := tracer.Start(ctx, "resource.server.Create") defer span.End() + // check quotas and log for now + s.checkQuota(ctx, NamespacedResource{ + Namespace: req.Key.Namespace, + Group: req.Key.Group, + Resource: req.Key.Resource, + }) + if r := verifyRequestKey(req.Key); r != nil { return nil, fmt.Errorf("invalid request key: %s", r.Message) } @@ -1549,3 +1575,31 @@ func (s *server) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildInde return s.search.RebuildIndexes(ctx, req) } + +func (s *server) checkQuota(ctx context.Context, nsr NamespacedResource) { + span := trace.SpanFromContext(ctx) + span.AddEvent("checkQuota", trace.WithAttributes( + attribute.String("namespace", nsr.Namespace), + attribute.String("group", nsr.Group), + attribute.String("resource", nsr.Resource), + )) + + if s.overridesService == nil { + return + } + + quota, err := s.overridesService.GetQuota(ctx, nsr) + if err != nil { + s.log.FromContext(ctx).Error("failed to get quota for resource", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err) + return + } + + stats, err := s.backend.GetResourceStats(ctx, nsr, 0) + if err != nil { + s.log.FromContext(ctx).Error("failed to get resource stats for quota checking", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err) + return + } + if len(stats) > 0 && stats[0].Count >= int64(quota.Limit) { + s.log.FromContext(ctx).Info("Quota exceeded on create", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "quota", quota.Limit, "count", stats[0].Count, "stats_resource", stats[0].Resource) + } +} diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 8de43f318b0..0abc2fd7329 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -14,6 +14,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/lib/pq" "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" "go.uber.org/atomic" @@ -263,7 +264,11 @@ func (b *backend) Stop(_ context.Context) error { // GetResourceStats implements Backend. func (b *backend) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) { - ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats") + ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats", trace.WithAttributes( + attribute.String("namespace", nsr.Namespace), + attribute.String("group", nsr.Group), + attribute.String("resource", nsr.Resource), + )) defer span.End() req := &sqlStatsRequest{ diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 1c2b02d8637..f91baa7a695 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -30,19 +30,20 @@ type QOSEnqueueDequeuer interface { // ServerOptions contains the options for creating a new ResourceServer type ServerOptions struct { - Backend resource.StorageBackend - DB infraDB.DB - Cfg *setting.Cfg - Tracer trace.Tracer - Reg prometheus.Registerer - AccessClient types.AccessClient - SearchOptions resource.SearchOptions - StorageMetrics *resource.StorageMetrics - IndexMetrics *resource.BleveIndexMetrics - Features featuremgmt.FeatureToggles - QOSQueue QOSEnqueueDequeuer - SecureValues secrets.InlineSecureValueSupport - OwnsIndexFn func(key resource.NamespacedResource) (bool, error) + Backend resource.StorageBackend + OverridesService *resource.OverridesService + DB infraDB.DB + Cfg *setting.Cfg + Tracer trace.Tracer + Reg prometheus.Registerer + AccessClient types.AccessClient + SearchOptions resource.SearchOptions + StorageMetrics *resource.StorageMetrics + IndexMetrics *resource.BleveIndexMetrics + Features featuremgmt.FeatureToggles + QOSQueue QOSEnqueueDequeuer + SecureValues secrets.InlineSecureValueSupport + OwnsIndexFn func(key resource.NamespacedResource) (bool, error) } func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { @@ -119,6 +120,7 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { serverOptions.IndexMetrics = opts.IndexMetrics serverOptions.QOSQueue = opts.QOSQueue serverOptions.OwnsIndexFn = opts.OwnsIndexFn + serverOptions.OverridesService = opts.OverridesService return resource.NewResourceServer(serverOptions) } diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 00e2ae73750..334c2dfee76 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -279,6 +279,18 @@ func (s *service) starting(ctx context.Context) error { QOSQueue: s.queue, OwnsIndexFn: s.OwnsIndex, } + + if s.cfg.OverridesFilePath != "" { + overridesSvc, err := resource.NewOverridesService(context.Background(), s.log, s.reg, s.tracing, resource.ReloadOptions{ + FilePath: s.cfg.OverridesFilePath, + ReloadPeriod: s.cfg.OverridesReloadInterval, + }) + if err != nil { + return err + } + serverOptions.OverridesService = overridesSvc + } + server, err := NewResourceServer(serverOptions) if err != nil { return err From eea50c8e9b3841fdcca73519ac399318a627d9ad Mon Sep 17 00:00:00 2001 From: Kevin Yu Date: Thu, 27 Nov 2025 08:58:43 -0800 Subject: [PATCH 21/31] Elasticsearch: Update codeowner for elasticsearchImprovedParsing feature toggle (#114556) --- pkg/services/featuremgmt/registry.go | 2 +- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.json | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b4b34f578fa..950476d1a8f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1322,7 +1322,7 @@ var ( Name: "elasticsearchImprovedParsing", Description: "Enables less memory intensive Elasticsearch result parsing", Stage: FeatureStageExperimental, - Owner: awsDatasourcesSquad, + Owner: grafanaPartnerPluginsSquad, }, { Name: "datasourceConnectionsTab", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 29af9d445f7..a7423d583ab 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -182,7 +182,7 @@ k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false -elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false +elasticsearchImprovedParsing,experimental,@grafana/partner-datasources,false,false,false datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false newLogsPanel,GA,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index fd29fa544ee..59ad50e79e2 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1214,13 +1214,16 @@ { "metadata": { "name": "elasticsearchImprovedParsing", - "resourceVersion": "1763734583253", - "creationTimestamp": "2025-01-15T17:05:54Z" + "resourceVersion": "1764260048941", + "creationTimestamp": "2025-01-15T17:05:54Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-27 16:14:08.941633 +0000 UTC" + } }, "spec": { "description": "Enables less memory intensive Elasticsearch result parsing", "stage": "experimental", - "codeowner": "@grafana/aws-datasources" + "codeowner": "@grafana/partner-datasources" } }, { From 5626dc50f86039aeab62bae9ef764c9bcc6244df Mon Sep 17 00:00:00 2001 From: Georges Chaudy Date: Thu, 27 Nov 2025 18:42:01 +0100 Subject: [PATCH 22/31] feat(unified-storage): Add adaptive backoff to event notifier polling (#114401) * use exponential backoff in notifier * Enhance BadgerDB configuration in REST options with memory table size and number of memtables * Enhance BadgerDB configuration in REST options by adding value threshold for LSM vs value log storage --- pkg/storage/unified/apistore/restoptions.go | 3 ++ pkg/storage/unified/resource/notifier.go | 40 +++++++++++++++---- pkg/storage/unified/resource/notifier_test.go | 16 +++++--- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/pkg/storage/unified/apistore/restoptions.go b/pkg/storage/unified/apistore/restoptions.go index b48fbc6deaf..d880c663f29 100644 --- a/pkg/storage/unified/apistore/restoptions.go +++ b/pkg/storage/unified/apistore/restoptions.go @@ -54,6 +54,9 @@ func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config, sec // Create BadgerDB with in-memory mode db, err := badger.Open(badger.DefaultOptions(""). WithInMemory(true). + WithMemTableSize(256 << 10). // 256KB memtable size + WithValueThreshold(16 << 10). // 16KB threshold for storing values in LSM vs value log + WithNumMemtables(2). // Keep only 2 memtables in memory WithLogger(nil)) if err != nil { return nil, err diff --git a/pkg/storage/unified/resource/notifier.go b/pkg/storage/unified/resource/notifier.go index f55db60623a..5dd6a17ad29 100644 --- a/pkg/storage/unified/resource/notifier.go +++ b/pkg/storage/unified/resource/notifier.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/grafana/dskit/backoff" "github.com/grafana/grafana-app-sdk/logging" gocache "github.com/patrickmn/go-cache" @@ -13,8 +14,8 @@ import ( const ( defaultLookbackPeriod = 30 * time.Second - defaultPollInterval = 100 * time.Millisecond - defaultEventCacheSize = 10000 + defaultMinBackoff = 100 * time.Millisecond + defaultMaxBackoff = 5 * time.Second defaultBufferSize = 10000 ) @@ -29,15 +30,17 @@ type notifierOptions struct { type watchOptions struct { LookbackPeriod time.Duration // How far back to look for events - PollInterval time.Duration // How often to poll for new events BufferSize int // How many events to buffer + MinBackoff time.Duration // Minimum interval between polling requests + MaxBackoff time.Duration // Maximum interval between polling requests } func defaultWatchOptions() watchOptions { return watchOptions{ LookbackPeriod: defaultLookbackPeriod, - PollInterval: defaultPollInterval, BufferSize: defaultBufferSize, + MinBackoff: defaultMinBackoff, + MaxBackoff: defaultMaxBackoff, } } @@ -62,9 +65,13 @@ func (n *notifier) cacheKey(evt Event) string { } func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { - if opts.PollInterval <= 0 { - opts.PollInterval = defaultPollInterval + if opts.MinBackoff <= 0 { + opts.MinBackoff = defaultMinBackoff } + if opts.MaxBackoff <= 0 || opts.MaxBackoff <= opts.MinBackoff { + opts.MaxBackoff = defaultMaxBackoff + } + cacheTTL := opts.LookbackPeriod cacheCleanupInterval := 2 * opts.LookbackPeriod @@ -81,11 +88,21 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { go func() { defer close(events) + // Initialize backoff with minimum backoff interval + currentInterval := opts.MinBackoff + backoffConfig := backoff.Config{ + MinBackoff: opts.MinBackoff, + MaxBackoff: opts.MaxBackoff, + MaxRetries: 0, // infinite retries + } + bo := backoff.New(ctx, backoffConfig) + for { select { case <-ctx.Done(): return - case <-time.After(opts.PollInterval): + case <-time.After(currentInterval): + foundEvents := false for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod)) { if err != nil { n.log.Error("Failed to list events since", "error", err) @@ -102,6 +119,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { continue } + foundEvents = true if evt.ResourceVersion > lastRV { lastRV = evt.ResourceVersion + 1 } @@ -113,6 +131,14 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { return } } + + // Apply backoff logic: reset to min when events are found, increase when no events + if foundEvents { + bo.Reset() + currentInterval = opts.MinBackoff + } else { + currentInterval = bo.NextDelay() + } } } }() diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index a4272a36698..7b201f47420 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -32,7 +32,6 @@ func TestDefaultWatchOptions(t *testing.T) { opts := defaultWatchOptions() assert.Equal(t, defaultLookbackPeriod, opts.LookbackPeriod) - assert.Equal(t, defaultPollInterval, opts.PollInterval) assert.Equal(t, defaultBufferSize, opts.BufferSize) } @@ -158,8 +157,9 @@ func TestNotifier_Watch_NoEvents(t *testing.T) { opts := watchOptions{ LookbackPeriod: 100 * time.Millisecond, - PollInterval: 50 * time.Millisecond, BufferSize: 10, + MinBackoff: 50 * time.Millisecond, + MaxBackoff: 500 * time.Millisecond, } events := notifier.Watch(ctx, opts) @@ -210,8 +210,9 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) { opts := watchOptions{ LookbackPeriod: 100 * time.Millisecond, - PollInterval: 50 * time.Millisecond, BufferSize: 10, + MinBackoff: 50 * time.Millisecond, + MaxBackoff: 500 * time.Millisecond, } // Start watching @@ -265,8 +266,9 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) { opts := watchOptions{ LookbackPeriod: time.Second, - PollInterval: 20 * time.Millisecond, BufferSize: 10, + MinBackoff: 20 * time.Millisecond, + MaxBackoff: 200 * time.Millisecond, } // Start watching @@ -326,8 +328,9 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) { opts := watchOptions{ LookbackPeriod: 100 * time.Millisecond, - PollInterval: 20 * time.Millisecond, BufferSize: 10, + MinBackoff: 20 * time.Millisecond, + MaxBackoff: 200 * time.Millisecond, } events := notifier.Watch(ctx, opts) @@ -369,8 +372,9 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) { opts := watchOptions{ LookbackPeriod: time.Second, - PollInterval: 20 * time.Millisecond, BufferSize: 10, + MinBackoff: 20 * time.Millisecond, + MaxBackoff: 200 * time.Millisecond, } // Start watching From 62d83a1ba93257d168f75e57ab94199061cec500 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Thu, 27 Nov 2025 15:50:47 -0400 Subject: [PATCH 23/31] Histogram: Fix runaway bucket densification with extremely sparse + large datasets (#114557) * test(histogram): failing test for runaway densification * fix(histogram): maximum bucket densification avoids OOM error * fix(histogram): handle multiple densified buckets --- .../transformers/histogram.test.ts | 36 +++++++++++++++++++ .../transformations/transformers/histogram.ts | 13 +++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/grafana-data/src/transformations/transformers/histogram.test.ts b/packages/grafana-data/src/transformations/transformers/histogram.test.ts index d7be8281adf..5597eb06787 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.test.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.test.ts @@ -896,6 +896,42 @@ describe('getHistogramFields', () => { } `); }); + + it('should prevent excessive densification when sparse histogram has large gaps', () => { + const result = getHistogramFields( + toDataFrame({ + meta: { + type: DataFrameType.HeatmapCells, + }, + fields: [ + { name: 'yMin', type: FieldType.number, values: [0.001, 1000] }, + { name: 'yMax', type: FieldType.number, values: [0.00101, 1010] }, + { name: 'count', type: FieldType.number, values: [10, 20] }, + ], + }) + ); + + expect(result).toBeDefined(); + expect(result!.counts[0].values.length).toBeLessThanOrEqual(1001); + }); + + it('should handle multiple observed buckets when hitting densification limit', () => { + const result = getHistogramFields( + toDataFrame({ + meta: { + type: DataFrameType.HeatmapCells, + }, + fields: [ + { name: 'yMin', type: FieldType.number, values: [0.001, 1000, 2000] }, + { name: 'yMax', type: FieldType.number, values: [0.00101, 1010, 2020] }, + { name: 'count', type: FieldType.number, values: [10, 20, 30] }, + ], + }) + ); + + expect(result).toBeDefined(); + expect(result!.counts[0].values.every((v) => !isNaN(v))).toBe(true); + }); }); describe('joinHistograms', () => { diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts index 723fb07b0d8..dfd095921d2 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -210,6 +210,8 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine let denseMins: number[] = []; let denseMaxs: number[] = []; + const MAX_DENSIFIED_BUCKETS = 1000; + for (let i = 0; i < uniqueMaxs.length; i++) { let curMax = uniqueMaxs[i]; let curMin = uniqueMins[i]; @@ -223,13 +225,17 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine curMax = curMax * bucketFactor; curMin = curMin * bucketFactor; - while (curMax < nextMax * 0.999999) { + while (curMax < nextMax * 0.999999 && denseMaxs.length < MAX_DENSIFIED_BUCKETS) { denseMaxs.push(curMax); denseMins.push(curMin); curMax = curMax * bucketFactor; curMin = curMin * bucketFactor; } + + if (denseMaxs.length >= MAX_DENSIFIED_BUCKETS) { + break; + } } } @@ -238,7 +244,10 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine for (let i = 0; i < yMaxField.values.length; i++) { let max = yMaxField.values[i]; - countsByMax.set(max, countsByMax.get(max) + countField.values[i]); + let currentCount = countsByMax.get(max); + if (currentCount !== undefined) { + countsByMax.set(max, currentCount + countField.values[i]); + } } let fields = { From 48a8d54794cc5d6bbdab4f5f4e8944cb2b8ee976 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 00:40:11 +0000 Subject: [PATCH 24/31] I18n: Download translations from Crowdin (#114565) 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 | 33 +++++++++++------------------ public/locales/de-DE/grafana.json | 33 +++++++++++------------------ public/locales/es-ES/grafana.json | 33 +++++++++++------------------ public/locales/fr-FR/grafana.json | 33 +++++++++++------------------ public/locales/hu-HU/grafana.json | 33 +++++++++++------------------ public/locales/id-ID/grafana.json | 33 +++++++++++------------------ public/locales/it-IT/grafana.json | 33 +++++++++++------------------ public/locales/ja-JP/grafana.json | 33 +++++++++++------------------ public/locales/ko-KR/grafana.json | 33 +++++++++++------------------ public/locales/nl-NL/grafana.json | 33 +++++++++++------------------ public/locales/pl-PL/grafana.json | 33 +++++++++++------------------ public/locales/pt-BR/grafana.json | 33 +++++++++++------------------ public/locales/pt-PT/grafana.json | 33 +++++++++++------------------ public/locales/ru-RU/grafana.json | 33 +++++++++++------------------ public/locales/sv-SE/grafana.json | 33 +++++++++++------------------ public/locales/tr-TR/grafana.json | 33 +++++++++++------------------ public/locales/zh-Hans/grafana.json | 33 +++++++++++------------------ public/locales/zh-Hant/grafana.json | 33 +++++++++++------------------ 18 files changed, 216 insertions(+), 378 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 5a27d2da0e2..73a8378bd20 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -11803,13 +11803,6 @@ "saving": "Ukládání", "title-error-loading-file": "Chyba při načítání souboru" }, - "files-view": { - "columns": { - "history": "Historie", - "view": "Zobrazit" - }, - "placeholder-search": "Hledat" - }, "finish-step": { "description-enable-previews": "Přidá náhled obrázku změn nástěnky v pull requestech. Obrázky nástěnek Grafana budou sdíleny ve vašem úložišti Git a uvidí je každý, kdo má přístup k úložišti.", "description-generate-dashboard-previews": "Vytvořte odkazy na náhledy pro žádosti o stažení", @@ -12024,9 +12017,6 @@ "source-code": "Zdrojový kód" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Jen pro čtení", "settings": "Nastavení", "view": "Zobrazit" @@ -12063,14 +12053,6 @@ "webhook-last-event": "Poslední událost:", "webhook-url": "Zobrazit webhook" }, - "repository-resources": { - "columns": { - "history": "Historie", - "view-dashboard": "Zobrazit", - "view-folder": "Zobrazit" - }, - "placeholder-search": "Hledat" - }, "repository-status-page": { "back-to-repositories": "Zpět na úložiště", "cleaning-up-resources": "Čištění zdrojů úložiště", @@ -12078,12 +12060,10 @@ "not-found": "nenalezeno", "not-found-message": "Úložiště nebylo nalezeno", "repository-config-exists-configuration": "Ujistěte se, že konfigurace úložiště existuje v konfiguračním souboru.", - "tab-files": "Soubory", - "tab-files-title": "Seznam nezpracovaných souborů z úložiště", "tab-overview": "Přehled", "tab-overview-title": "Přehled úložiště", "tab-resources": "Zdroje", - "tab-resources-title": "Zdroje uložené v databázi Grafany", + "tab-resources-title": "", "title": "Stav úložiště", "title-legacy-storage": "Starší verze úložiště", "title-queued-for-deletion": "Zařazeno do fronty k odstranění" @@ -12106,6 +12086,17 @@ "pure-git": "Pouze Git", "pure-git-description": "Připojit k jakémukoli úložišti Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Základ", "dashboard-preview": "Náhled nástěnky", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 983ccc5868b..469a0ddfef8 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Einsparen", "title-error-loading-file": "Fehler beim Laden der Datei" }, - "files-view": { - "columns": { - "history": "Verlauf", - "view": "Anzeigen" - }, - "placeholder-search": "Suche" - }, "finish-step": { "description-enable-previews": "Fügt eine Bildvorschau der Dashboard-Änderungen bei Pull-Requests hinzu. Bilder Ihrer Grafana-Dashboards werden in Ihrem Git-Repository bereitgestellt und sind für jede Person mit Repository-Zugriff sichtbar.", "description-generate-dashboard-previews": "Erstellen Sie Vorschau-Links für Pull-Requests", @@ -11920,9 +11913,6 @@ "source-code": "Quellcode" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Schreibgeschützt", "settings": "Einstellungen", "view": "Anzeigen" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Letztes Ereignis:", "webhook-url": "Webhook anzeigen" }, - "repository-resources": { - "columns": { - "history": "Verlauf", - "view-dashboard": "Anzeigen", - "view-folder": "Anzeigen" - }, - "placeholder-search": "Suche" - }, "repository-status-page": { "back-to-repositories": "Zurück zu den Repositorys", "cleaning-up-resources": "Bereinigen von Repository-Ressourcen", @@ -11974,12 +11956,10 @@ "not-found": "nicht gefunden", "not-found-message": "Repository nicht gefunden", "repository-config-exists-configuration": "Achten Sie darauf, dass die Repository-config in der Konfigurationsdatei vorhanden ist.", - "tab-files": "Dateien", - "tab-files-title": "Die Raw-Datei-Liste aus dem Repository", "tab-overview": "Übersicht", "tab-overview-title": "Repository-Übersicht", "tab-resources": "Ressourcen", - "tab-resources-title": "In der Grafana-Datenbank gespeicherte Ressourcen", + "tab-resources-title": "", "title": "Repository-Status", "title-legacy-storage": "Veralteter Speicher", "title-queued-for-deletion": "Zum Löschen in die Warteschlange gestellt" @@ -12002,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Mit beliebigem Git-Repository verbinden" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Basis", "dashboard-preview": "Dashboard-Vorschau", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index bca3dd9a0df..db55d7dbe2b 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Guardando", "title-error-loading-file": "Error al cargar el archivo" }, - "files-view": { - "columns": { - "history": "Historial", - "view": "Vista" - }, - "placeholder-search": "Buscar" - }, "finish-step": { "description-enable-previews": "Añade una vista previa de la imagen de los cambios del dashboard en las solicitudes de extracción. Las imágenes de tus paneles de Grafana se compartirán en tu repositorio Git y serán visibles para cualquier persona con acceso al repositorio.", "description-generate-dashboard-previews": "Crear enlaces de vista previa para las solicitudes de extracción", @@ -11920,9 +11913,6 @@ "source-code": "Código fuente" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Solo lectura", "settings": "Configuración", "view": "Vista" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Último evento:", "webhook-url": "Ver webhook" }, - "repository-resources": { - "columns": { - "history": "Historial", - "view-dashboard": "Vista", - "view-folder": "Vista" - }, - "placeholder-search": "Buscar" - }, "repository-status-page": { "back-to-repositories": "Volver a los repositorios", "cleaning-up-resources": "Limpiando los recursos del repositorio", @@ -11974,12 +11956,10 @@ "not-found": "no ha sido encontrado", "not-found-message": "Repositorio no encontrado", "repository-config-exists-configuration": "Asegúrate de que la configuración del repositorio exista en el archivo de configuración.", - "tab-files": "Archivos", - "tab-files-title": "La lista de archivos sin procesar del repositorio", "tab-overview": "Resumen", "tab-overview-title": "Resumen del repositorio", "tab-resources": "Recursos", - "tab-resources-title": "Recursos guardados en la base de datos de Grafana", + "tab-resources-title": "", "title": "Estado del repositorio", "title-legacy-storage": "Almacenamiento heredado", "title-queued-for-deletion": "En cola para su eliminación" @@ -12002,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Conectar a cualquier repositorio Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Vista previa del dashboard", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index c24f8a94696..e0b0cd18363 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Enregistrement en cours", "title-error-loading-file": "Erreur lors du chargement du fichier" }, - "files-view": { - "columns": { - "history": "Historique", - "view": "Afficher" - }, - "placeholder-search": "Rechercher" - }, "finish-step": { "description-enable-previews": "Ajoute un aperçu des images des modifications apportées au tableau de bord dans les demandes de fusion. Les images de vos tableaux de bord Grafana seront partagées dans votre référentiel Git et visibles par toute personne ayant accès au référentiel.", "description-generate-dashboard-previews": "Créer des liens de prévisualisation pour les demandes de tirage", @@ -11920,9 +11913,6 @@ "source-code": "Code source" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Lecture seule", "settings": "Paramètres", "view": "Afficher" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Dernier événement :", "webhook-url": "Voir le webhook" }, - "repository-resources": { - "columns": { - "history": "Historique", - "view-dashboard": "Afficher", - "view-folder": "Afficher" - }, - "placeholder-search": "Rechercher" - }, "repository-status-page": { "back-to-repositories": "Retour aux référentiels", "cleaning-up-resources": "Nettoyage des ressources du référentiel", @@ -11974,12 +11956,10 @@ "not-found": "introuvable", "not-found-message": "Référentiel introuvable", "repository-config-exists-configuration": "Assurez-vous que la configuration du référentiel existe dans le fichier de configuration.", - "tab-files": "Fichiers", - "tab-files-title": "La liste des fichiers bruts du référentiel", "tab-overview": "Vue d’ensemble", "tab-overview-title": "Vue d’ensemble du référentiel", "tab-resources": "Ressources", - "tab-resources-title": "Ressources enregistrées dans la base de données Grafana", + "tab-resources-title": "", "title": "Statut du référentiel", "title-legacy-storage": "Stockage hérité", "title-queued-for-deletion": "Mis en attente pour suppression" @@ -12002,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Se connecter à un quelconque dépôt Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Aperçu du tableau de bord", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 287f64d1b3d..3ff7e30485b 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Mentés", "title-error-loading-file": "Hiba a fájl betöltésekor" }, - "files-view": { - "columns": { - "history": "Előzmények", - "view": "Nézet" - }, - "placeholder-search": "Keresés" - }, "finish-step": { "description-enable-previews": "Hozzáadja az irányítópult változásainak előnézetét a lekérésekben. A Grafana-irányítópultok képei meg lesznek osztva a Git-tárban, és bárki számára láthatóak lesznek, aki hozzáféréssel rendelkezik az adattárhoz.", "description-generate-dashboard-previews": "Előnézeti hivatkozások létrehozása az összefésülési kérelmekhez", @@ -11920,9 +11913,6 @@ "source-code": "Forráskód" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webkapocs" - }, "read-only-badge": "Csak olvasható", "settings": "Beállítások", "view": "Nézet" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Legutóbbi esemény:", "webhook-url": "Webkapocs megtekintése" }, - "repository-resources": { - "columns": { - "history": "Előzmények", - "view-dashboard": "Nézet", - "view-folder": "Nézet" - }, - "placeholder-search": "Keresés" - }, "repository-status-page": { "back-to-repositories": "Vissza az adattárakhoz", "cleaning-up-resources": "Adattári erőforrások tisztítása", @@ -11974,12 +11956,10 @@ "not-found": "nem található", "not-found-message": "Nem található adattár", "repository-config-exists-configuration": "Győződjön meg arról, hogy a tároló konfigurációja létezik a konfigurációs fájlban.", - "tab-files": "Fájlok", - "tab-files-title": "Nyers fájllista az adattárból", "tab-overview": "Áttekintés", "tab-overview-title": "Adattár áttekintése", "tab-resources": "Erőforrások", - "tab-resources-title": "Grafana-adatbázisba mentett erőforrások", + "tab-resources-title": "", "title": "Adattár állapota", "title-legacy-storage": "Örökölt tárolás", "title-queued-for-deletion": "Törlésre vár" @@ -12002,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Csatlakozás bármely Git-adattárhoz" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Alap", "dashboard-preview": "Irányítópult előnézete", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index cb248ff1494..a6708a09472 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -11653,13 +11653,6 @@ "saving": "Menyimpan", "title-error-loading-file": "Kesalahan saat memuat file" }, - "files-view": { - "columns": { - "history": "Sejarah", - "view": "Lihat" - }, - "placeholder-search": "Cari" - }, "finish-step": { "description-enable-previews": "Menambahkan pratinjau gambar dari perubahan dasbor di permintaan pull. Gambar dasbor Grafana Anda akan dibagikan di repositori Git Anda dan dapat dilihat oleh siapa saja yang memiliki akses repositori.", "description-generate-dashboard-previews": "Buat tautan pratinjau untuk permintaan penarikan", @@ -11868,9 +11861,6 @@ "source-code": "Kode sumber" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Hanya baca", "settings": "Pengaturan", "view": "Lihat" @@ -11907,14 +11897,6 @@ "webhook-last-event": "Peristiwa Terakhir:", "webhook-url": "Lihat Webhook" }, - "repository-resources": { - "columns": { - "history": "Sejarah", - "view-dashboard": "Lihat", - "view-folder": "Lihat" - }, - "placeholder-search": "Cari" - }, "repository-status-page": { "back-to-repositories": "Kembali ke repositori", "cleaning-up-resources": "Membersihkan sumber daya repositori", @@ -11922,12 +11904,10 @@ "not-found": "tidak ditemukan", "not-found-message": "Repositori tidak ditemukan", "repository-config-exists-configuration": "Pastikan konfigurasi repositori ada dalam file konfigurasi.", - "tab-files": "File", - "tab-files-title": "Daftar file mentah dari repositori", "tab-overview": "Gambaran Umum", "tab-overview-title": "Gambaran umum repositori", "tab-resources": "Sumber Daya", - "tab-resources-title": "Sumber daya disimpan dalam database grafana", + "tab-resources-title": "", "title": "Status Repositori", "title-legacy-storage": "Penyimpanan Lama", "title-queued-for-deletion": "Diantrekan untuk dihapus" @@ -11950,6 +11930,17 @@ "pure-git": "Pure Git", "pure-git-description": "Hubungkan ke repositori Git mana pun" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Dasar", "dashboard-preview": "Pratinjau Dasbor", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 731218c4f16..e5fc8647a20 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Salvataggio in corso", "title-error-loading-file": "Errore nel caricamento del file" }, - "files-view": { - "columns": { - "history": "Cronologia", - "view": "Visualizza" - }, - "placeholder-search": "Cerca" - }, "finish-step": { "description-enable-previews": "Aggiunge un'anteprima dell'immagine delle modifiche alla dashboard nelle richieste di pull. Le immagini delle dashboard Grafana verranno condivise nel repository Git e saranno visibili a chiunque abbia accesso al repository.", "description-generate-dashboard-previews": "Crea collegamenti di anteprima per le richieste di pull", @@ -11920,9 +11913,6 @@ "source-code": "Codice sorgente" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Solo lettura", "settings": "Impostazioni", "view": "Visualizza" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Ultimo evento:", "webhook-url": "Visualizza webhook" }, - "repository-resources": { - "columns": { - "history": "Cronologia", - "view-dashboard": "Visualizza", - "view-folder": "Visualizza" - }, - "placeholder-search": "Cerca" - }, "repository-status-page": { "back-to-repositories": "Torna ai repository", "cleaning-up-resources": "Pulizia delle risorse del repository", @@ -11974,12 +11956,10 @@ "not-found": "non trovato", "not-found-message": "Repository non trovato", "repository-config-exists-configuration": "Assicurati che la configurazione del repository esista nel file di configurazione.", - "tab-files": "File", - "tab-files-title": "L'elenco dei file non elaborati dal repository", "tab-overview": "Panoramica", "tab-overview-title": "Panoramica del repository", "tab-resources": "Risorse", - "tab-resources-title": "Risorse salvate nel database di Grafana", + "tab-resources-title": "", "title": "Stato del repository", "title-legacy-storage": "Memoria esistente", "title-queued-for-deletion": "In coda per l'eliminazione" @@ -12002,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Connetti a qualsiasi repository Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Anteprima dashboard", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 5eb117faa50..fa3f87eabf4 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -11653,13 +11653,6 @@ "saving": "保存中", "title-error-loading-file": "ファイル読み込み時のエラー" }, - "files-view": { - "columns": { - "history": "履歴", - "view": "表示" - }, - "placeholder-search": "検索" - }, "finish-step": { "description-enable-previews": "プルリクエストによるダッシュボードの変更の画像プレビューを追加します。Grafanaダッシュボードの画像はGitリポジトリで共有され、リポジトリにアクセスできる全ユーザーが閲覧できます。", "description-generate-dashboard-previews": "プルリクエストのプレビューリンクを作成する", @@ -11868,9 +11861,6 @@ "source-code": "ソースコード" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "読み取り専用", "settings": "設定", "view": "表示" @@ -11907,14 +11897,6 @@ "webhook-last-event": "最新イベント:", "webhook-url": "Webhookを表示" }, - "repository-resources": { - "columns": { - "history": "履歴", - "view-dashboard": "表示", - "view-folder": "表示" - }, - "placeholder-search": "検索" - }, "repository-status-page": { "back-to-repositories": "リポジトリに戻る", "cleaning-up-resources": "リポジトリリソースのクリーンアップ中", @@ -11922,12 +11904,10 @@ "not-found": "見つかりません", "not-found-message": "リポジトリが見つかりません", "repository-config-exists-configuration": "リポジトリ設定が設定ファイルに含まれていることを確認してください。", - "tab-files": "ファイル", - "tab-files-title": "リポジトリからのRawファイルリスト", "tab-overview": "概要", "tab-overview-title": "リポジトリの概要", "tab-resources": "リソース", - "tab-resources-title": "Grafanaデータベースに保存されたリソース", + "tab-resources-title": "", "title": "リポジトリの状態", "title-legacy-storage": "レガシーストレージ", "title-queued-for-deletion": "削除待ちリストに追加されました" @@ -11950,6 +11930,17 @@ "pure-git": "Pure Git", "pure-git-description": "任意のGitリポジトリに接続" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "ベース", "dashboard-preview": "ダッシュボードプレビュー", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 1cc6b2ab0a1..cf9773184cd 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -11653,13 +11653,6 @@ "saving": "저장 중", "title-error-loading-file": "파일 로딩 중 오류 발생" }, - "files-view": { - "columns": { - "history": "이력", - "view": "보기" - }, - "placeholder-search": "검색" - }, "finish-step": { "description-enable-previews": "풀 요청에서 대시보드 변경 사항의 이미지 미리 보기를 추가합니다. Grafana 대시보드의 이미지는 Git 리포지토리에서 공유되며 리포지토리 액세스 권한이 있는 모든 사용자가 볼 수 있습니다.", "description-generate-dashboard-previews": "풀 요청에 대한 미리 보기 링크 생성", @@ -11868,9 +11861,6 @@ "source-code": "소스 코드" }, "repository-card": { - "get-repository-meta": { - "webhook": "웹훅" - }, "read-only-badge": "읽기 전용", "settings": "설정", "view": "보기" @@ -11907,14 +11897,6 @@ "webhook-last-event": "마지막 이벤트:", "webhook-url": "웹훅 보기" }, - "repository-resources": { - "columns": { - "history": "이력", - "view-dashboard": "보기", - "view-folder": "보기" - }, - "placeholder-search": "검색" - }, "repository-status-page": { "back-to-repositories": "리포지토리로 돌아가기", "cleaning-up-resources": "리포지토리 리소스 정리 및 삭제 중", @@ -11922,12 +11904,10 @@ "not-found": "찾을 수 없음", "not-found-message": "리포지토리를 찾을 수 없습니다", "repository-config-exists-configuration": "리포지토리 구성이 구성 파일에 있는지 확인하세요.", - "tab-files": "파일", - "tab-files-title": "리포지토리의 원시 파일 목록", "tab-overview": "개요", "tab-overview-title": "리포지토리 개요", "tab-resources": "리소스", - "tab-resources-title": "Grafana 데이터베이스에 저장된 리소스", + "tab-resources-title": "", "title": "리포지토리 상태", "title-legacy-storage": "레거시 스토리지", "title-queued-for-deletion": "삭제 대기열에 추가됨" @@ -11950,6 +11930,17 @@ "pure-git": "Git으로만 구성", "pure-git-description": "모든 Git 리포지토리에 연결" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "베이스", "dashboard-preview": "대시보드 미리 보기", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index b66136b8183..8a7dcc3b2d0 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Opslaan", "title-error-loading-file": "Er is een fout opgetreden bij het laden van het bestand" }, - "files-view": { - "columns": { - "history": "Geschiedenis", - "view": "Weergave" - }, - "placeholder-search": "Zoeken" - }, "finish-step": { "description-enable-previews": "Voegt een afbeeldingsvoorbeeld toe van dashboardwijzigingen in pull requests. Afbeeldingen van je Grafana-dashboards worden gedeeld in je Git-repository en zijn zichtbaar voor iedereen met toegang tot de repository.", "description-generate-dashboard-previews": "Voorbeeldlinks maken voor pull-verzoeken", @@ -11920,9 +11913,6 @@ "source-code": "Broncode" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Alleen lezen", "settings": "Instellingen", "view": "Weergave" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Laatste gebeurtenis:", "webhook-url": "Webhook bekijken" }, - "repository-resources": { - "columns": { - "history": "Geschiedenis", - "view-dashboard": "Weergave", - "view-folder": "Weergave" - }, - "placeholder-search": "Zoeken" - }, "repository-status-page": { "back-to-repositories": "Terug naar repositories", "cleaning-up-resources": "Bronnen van repository opschonen", @@ -11974,12 +11956,10 @@ "not-found": "niet gevonden", "not-found-message": "Repository niet gevonden", "repository-config-exists-configuration": "Zorg ervoor dat de repository-configuratie in het configuratiebestand bestaat.", - "tab-files": "Bestanden", - "tab-files-title": "De lijst met onbewerkte bestanden uit de repository", "tab-overview": "Overzicht", "tab-overview-title": "Repository-overzicht", "tab-resources": "Bronnen", - "tab-resources-title": "Bronnen opgeslagen in Grafana-database", + "tab-resources-title": "", "title": "Repository-status", "title-legacy-storage": "Legacy-opslag", "title-queued-for-deletion": "In de wachtrij voor verwijdering" @@ -12002,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Verbinden met elke Git-repository" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Basis", "dashboard-preview": "Dashboardvoorbeeld", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 233b9ed0a50..d988de6fdee 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -11803,13 +11803,6 @@ "saving": "Zapisywanie", "title-error-loading-file": "Błąd podczas ładowania pliku" }, - "files-view": { - "columns": { - "history": "Historia", - "view": "Wyświetl" - }, - "placeholder-search": "Szukaj" - }, "finish-step": { "description-enable-previews": "Dodaje podgląd obrazu zmian pulpitu w żądaniach pull. Obrazy pulpitów Grafany zostaną udostępnione w repozytorium Git i będą widoczne dla każdego, kto ma do niego dostęp.", "description-generate-dashboard-previews": "Utwórz linki podglądu dla żądań pull", @@ -12024,9 +12017,6 @@ "source-code": "Kod źródłowy" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Tylko do odczytu", "settings": "Ustawienia", "view": "Wyświetl" @@ -12063,14 +12053,6 @@ "webhook-last-event": "Ostatnie zdarzenie:", "webhook-url": "Wyświetl element webhook" }, - "repository-resources": { - "columns": { - "history": "Historia", - "view-dashboard": "Wyświetl", - "view-folder": "Wyświetl" - }, - "placeholder-search": "Szukaj" - }, "repository-status-page": { "back-to-repositories": "Wróć do repozytoriów", "cleaning-up-resources": "Sprzątanie zasobów repozytorium", @@ -12078,12 +12060,10 @@ "not-found": "nie znaleziono", "not-found-message": "Nie znaleziono repozytorium", "repository-config-exists-configuration": "Upewnij się, że konfiguracja repozytorium istnieje w pliku konfiguracyjnym.", - "tab-files": "Pliki", - "tab-files-title": "Lista nieprzetworzonych plików z repozytorium", "tab-overview": "Przegląd", "tab-overview-title": "Przegląd repozytorium", "tab-resources": "Zasoby", - "tab-resources-title": "Zasoby zapisane w bazie danych Grafany", + "tab-resources-title": "", "title": "Status repozytorium", "title-legacy-storage": "Starsza pamięć masowa", "title-queued-for-deletion": "Dodano do kolejki do usunięcia" @@ -12106,6 +12086,17 @@ "pure-git": "Czysty Git", "pure-git-description": "Połącz z dowolnym repozytorium Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Podstawa", "dashboard-preview": "Podgląd pulpitu", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 411c26f63fb..e17ed3dade4 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Salvando", "title-error-loading-file": "Erro ao carregar o arquivo" }, - "files-view": { - "columns": { - "history": "Histórico", - "view": "Visualizar" - }, - "placeholder-search": "Pesquisar" - }, "finish-step": { "description-enable-previews": "Adiciona uma pré-visualização em imagem das alterações do painel nas solicitações de extração. As imagens dos seus painéis da Grafana serão compartilhadas no seu repositório Git e estarão disponíveis para qualquer pessoa com acesso ao repositório.", "description-generate-dashboard-previews": "Criar links de prévia para solicitações de extração", @@ -11920,9 +11913,6 @@ "source-code": "Código fonte" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Somente leitura", "settings": "Configurações", "view": "Visualizar" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, - "repository-resources": { - "columns": { - "history": "Histórico", - "view-dashboard": "Visualizar", - "view-folder": "Visualizar" - }, - "placeholder-search": "Pesquisar" - }, "repository-status-page": { "back-to-repositories": "Voltar para os repositórios", "cleaning-up-resources": "Limpando recursos do repositório", @@ -11974,12 +11956,10 @@ "not-found": "não encontrado", "not-found-message": "Repositório não encontrado", "repository-config-exists-configuration": "Verifique se a configuração do repositório existe no arquivo de configuração.", - "tab-files": "Arquivos", - "tab-files-title": "A lista de arquivos brutos do repositório", "tab-overview": "Visão geral", "tab-overview-title": "Visão geral do repositório", "tab-resources": "Fontes", - "tab-resources-title": "Recursos salvos no banco de dados da Grafana", + "tab-resources-title": "", "title": "Status do repositório", "title-legacy-storage": "Armazenamento legado", "title-queued-for-deletion": "Na fila para exclusão" @@ -12002,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Conectar a qualquer repositório do Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Pré-visualização do painel", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index a97f0c759f6..34cc8be7717 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -11703,13 +11703,6 @@ "saving": "A guardar", "title-error-loading-file": "Erro ao carregar o ficheiro" }, - "files-view": { - "columns": { - "history": "Histórico", - "view": "Ver" - }, - "placeholder-search": "Pesquisar" - }, "finish-step": { "description-enable-previews": "Adicionar uma pré-visualização de imagem das alterações do painel de controlo nos pedidos de extração. As imagens dos seus painéis de controlo Grafana serão partilhadas no seu repositório Git e visíveis para qualquer pessoa com acesso ao repositório.", "description-generate-dashboard-previews": "Criar links de pré-visualização para pedidos de extração", @@ -11920,9 +11913,6 @@ "source-code": "Código-fonte" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Apenas para leitura", "settings": "Definições", "view": "Ver" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, - "repository-resources": { - "columns": { - "history": "Histórico", - "view-dashboard": "Ver", - "view-folder": "Ver" - }, - "placeholder-search": "Pesquisar" - }, "repository-status-page": { "back-to-repositories": "Voltar aos repositórios", "cleaning-up-resources": "A limpar recursos do repositório", @@ -11974,12 +11956,10 @@ "not-found": "não encontrado", "not-found-message": "Repositório não encontrado", "repository-config-exists-configuration": "Certifique-se de que a configuração do repositório existe no ficheiro de configuração.", - "tab-files": "Ficheiros", - "tab-files-title": "A lista de ficheiros sem processar do repositório", "tab-overview": "Visão geral", "tab-overview-title": "Vista geral do repositório", "tab-resources": "Recursos", - "tab-resources-title": "Recursos guardados na base de dados da Grafana", + "tab-resources-title": "", "title": "Estado do repositório", "title-legacy-storage": "Armazenamento herdado", "title-queued-for-deletion": "Em fila para eliminação" @@ -12002,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Ligar a qualquer repositório Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Pré-visualização do painel de controlo", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index e916e740995..3a8724d3098 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -11803,13 +11803,6 @@ "saving": "Сохранение", "title-error-loading-file": "Ошибка при загрузке файла" }, - "files-view": { - "columns": { - "history": "История", - "view": "Просмотр" - }, - "placeholder-search": "Поиск" - }, "finish-step": { "description-enable-previews": "Обеспечивает возможность просмотра изображений с изменениями дашборда в запросах на включение изменений. Изображения дашбордов Grafana будут публиковаться в вашем репозитории Git и видны всем, у кого есть доступ к репозиторию.", "description-generate-dashboard-previews": "Создать ссылки для предварительного просмотра запросов на включение изменений", @@ -12024,9 +12017,6 @@ "source-code": "Исходный код" }, "repository-card": { - "get-repository-meta": { - "webhook": "Веб-перехватчик" - }, "read-only-badge": "Только для чтения", "settings": "Параметры", "view": "Просмотр" @@ -12063,14 +12053,6 @@ "webhook-last-event": "Последнее событие:", "webhook-url": "Просмотр веб-перехватчика" }, - "repository-resources": { - "columns": { - "history": "История", - "view-dashboard": "Просмотр", - "view-folder": "Просмотр" - }, - "placeholder-search": "Поиск" - }, "repository-status-page": { "back-to-repositories": "Назад к репозиториям", "cleaning-up-resources": "Очистка ресурсов репозитория", @@ -12078,12 +12060,10 @@ "not-found": "не найдено", "not-found-message": "Репозиторий не найден", "repository-config-exists-configuration": "Убедитесь, что конфигурация репозитория существует в файле конфигурации.", - "tab-files": "Файлы", - "tab-files-title": "Список необработанных файлов из репозитория", "tab-overview": "Обзор", "tab-overview-title": "Обзор репозитория", "tab-resources": "Ресурсы", - "tab-resources-title": "Ресурсы, сохраненные в базе данных Grafana", + "tab-resources-title": "", "title": "Состояние репозитория", "title-legacy-storage": "Устаревшее хранилище", "title-queued-for-deletion": "В очереди на удаление" @@ -12106,6 +12086,17 @@ "pure-git": "Pure Git", "pure-git-description": "Подключиться к любому репозиторию Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Основа", "dashboard-preview": "Просмотр дашборда", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 38758ccf40e..c0bd748a996 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Sparar", "title-error-loading-file": "Ett fel uppstod när en fil laddades" }, - "files-view": { - "columns": { - "history": "Historik", - "view": "Visa" - }, - "placeholder-search": "Sök" - }, "finish-step": { "description-enable-previews": "Lägger till en förhandsgranskning av ändringar i instrumentpanelen för pull-förfrågningar. Bilder av dina Grafana-instrumentpaneler kommer att delas på din Git-lagringsplats och vara synliga för alla med lagringsåtkomst.", "description-generate-dashboard-previews": "Skapa förhandsgranskningslänkar för pull-begäranden", @@ -11920,9 +11913,6 @@ "source-code": "Källkod" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Skrivskyddad", "settings": "Inställningar", "view": "Visa" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Senaste händelsen:", "webhook-url": "Visa webhook" }, - "repository-resources": { - "columns": { - "history": "Historik", - "view-dashboard": "Visa", - "view-folder": "Visa" - }, - "placeholder-search": "Sök" - }, "repository-status-page": { "back-to-repositories": "Tillbaka till lagringsplatserna", "cleaning-up-resources": "Rensa lagringsplatsresurser", @@ -11974,12 +11956,10 @@ "not-found": "hittades inte", "not-found-message": "Lagringsplatsen hittades inte", "repository-config-exists-configuration": "Verifiera att lagringsplatskonfigurationen finns i konfigurationsfilen.", - "tab-files": "Filer", - "tab-files-title": "Rådatafillistan från lagringsplatsen", "tab-overview": "Översikt", "tab-overview-title": "Översikt över lagringsplats", "tab-resources": "Resurser", - "tab-resources-title": "Resurser sparade i Grafana-databasen", + "tab-resources-title": "", "title": "Status för lagringsplats", "title-legacy-storage": "Äldre lagring", "title-queued-for-deletion": "Köad för radering" @@ -12002,6 +11982,17 @@ "pure-git": "Ren Git", "pure-git-description": "Anslut till valfri Git-lagringsplats" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Bas", "dashboard-preview": "Förhandsgranskning av instrumentpanel", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index c727847d792..cfeb21bd5aa 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -11703,13 +11703,6 @@ "saving": "Kaydediliyor", "title-error-loading-file": "Dosya yüklenirken hata oluştu" }, - "files-view": { - "columns": { - "history": "Geçmiş", - "view": "Görüntüle" - }, - "placeholder-search": "Ara" - }, "finish-step": { "description-enable-previews": "Çekme isteklerinde pano değişikliklerinin görsel ön izlemesini ekler. Grafana panolarınızın görselleri Git deponuzda paylaşılacak ve depo erişimi olan herkes tarafından görülebilecektir.", "description-generate-dashboard-previews": "Çekme istekleri için ön izleme bağlantıları oluşturun", @@ -11920,9 +11913,6 @@ "source-code": "Kaynak kodu" }, "repository-card": { - "get-repository-meta": { - "webhook": "Web kancası" - }, "read-only-badge": "", "settings": "Ayarlar", "view": "Görüntüle" @@ -11959,14 +11949,6 @@ "webhook-last-event": "Son Olay:", "webhook-url": "Web Kancasını Görüntüle" }, - "repository-resources": { - "columns": { - "history": "Geçmiş", - "view-dashboard": "Görüntüle", - "view-folder": "Görüntüle" - }, - "placeholder-search": "Ara" - }, "repository-status-page": { "back-to-repositories": "Depolara geri dön", "cleaning-up-resources": "Depo kaynakları temizleniyor", @@ -11974,12 +11956,10 @@ "not-found": "bulunamadı", "not-found-message": "Depo bulunamadı", "repository-config-exists-configuration": "Depo yapılandırmasının yapılandırma dosyasında mevcut olduğundan emin olun.", - "tab-files": "Dosyalar", - "tab-files-title": "Depodan ham dosya listesi", "tab-overview": "Genel Bakış", "tab-overview-title": "Depoya genel bakış", "tab-resources": "Kaynaklar", - "tab-resources-title": "Grafana veri tabanına kaydedilen kaynaklar", + "tab-resources-title": "", "title": "Depo Durumu", "title-legacy-storage": "Eski Depolama", "title-queued-for-deletion": "Silinmek üzere kuyruğa alındı" @@ -12002,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Herhangi bir Git deposuna bağlanın" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Temel", "dashboard-preview": "Pano Ön İzlemesi", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 9e5952f913f..e96f271b082 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -11653,13 +11653,6 @@ "saving": "正在保存", "title-error-loading-file": "加载文件时出错" }, - "files-view": { - "columns": { - "history": "历史记录", - "view": "查看" - }, - "placeholder-search": "搜索" - }, "finish-step": { "description-enable-previews": "在拉取请求中添加数据面板更改的图像预览。Grafana 数据面板的图像将在 Git 存储库中共享,并对具有存储库访问权限的任何人可见。", "description-generate-dashboard-previews": "创建拉取请求的预览链接", @@ -11868,9 +11861,6 @@ "source-code": "源代码" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "只读", "settings": "设置", "view": "查看" @@ -11907,14 +11897,6 @@ "webhook-last-event": "最后一个事件:", "webhook-url": "查看 Webhook" }, - "repository-resources": { - "columns": { - "history": "历史记录", - "view-dashboard": "查看", - "view-folder": "查看" - }, - "placeholder-search": "搜索" - }, "repository-status-page": { "back-to-repositories": "回到存储库", "cleaning-up-resources": "清理存储库资源", @@ -11922,12 +11904,10 @@ "not-found": "找不到", "not-found-message": "找不到存储库", "repository-config-exists-configuration": "确保存储库配置存在于配置文件中。", - "tab-files": "文件", - "tab-files-title": "来自存储库的原始文件列表", "tab-overview": "概述", "tab-overview-title": "存储库概览", "tab-resources": "资源", - "tab-resources-title": "保存在 Grafana 数据库中的资源", + "tab-resources-title": "", "title": "存储库状态", "title-legacy-storage": "传统存储", "title-queued-for-deletion": "已进入队列等待删除" @@ -11950,6 +11930,17 @@ "pure-git": "纯 Git", "pure-git-description": "连接到任何 Git 存储库" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "基本", "dashboard-preview": "数据面板预览", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 1d2f8294d87..561d6b53c3a 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -11653,13 +11653,6 @@ "saving": "正在儲存", "title-error-loading-file": "載入檔案時發生錯誤" }, - "files-view": { - "columns": { - "history": "歷史紀錄", - "view": "檢視" - }, - "placeholder-search": "搜尋" - }, "finish-step": { "description-enable-previews": "在拉取請求中新增儀表板變更的圖片預覽。您的 Grafana 儀表板圖像將在您的 Git 儲存庫中共用,並且任何具有儲存庫存取權限者都可以看見。", "description-generate-dashboard-previews": "建立拉取請求的預覽連結", @@ -11868,9 +11861,6 @@ "source-code": "原始碼" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "唯讀", "settings": "設定", "view": "檢視" @@ -11907,14 +11897,6 @@ "webhook-last-event": "上次事件:", "webhook-url": "檢視 Webhook" }, - "repository-resources": { - "columns": { - "history": "歷史紀錄", - "view-dashboard": "檢視", - "view-folder": "檢視" - }, - "placeholder-search": "搜尋" - }, "repository-status-page": { "back-to-repositories": "返回至儲存庫", "cleaning-up-resources": "清理儲存庫資源", @@ -11922,12 +11904,10 @@ "not-found": "找不到", "not-found-message": "找不到儲存庫", "repository-config-exists-configuration": "請確認儲存庫設定存在於設定檔案中。", - "tab-files": "檔案", - "tab-files-title": "儲存庫中的原始檔案清單", "tab-overview": "概覽", "tab-overview-title": "儲存庫概覽", "tab-resources": "資源", - "tab-resources-title": "資源儲存在 grafana 資料庫中", + "tab-resources-title": "", "title": "儲存庫狀態", "title-legacy-storage": "舊版儲存空間", "title-queued-for-deletion": "已排入刪除佇列" @@ -11950,6 +11930,17 @@ "pure-git": "純 Git", "pure-git-description": "連接到任何 Git 儲存庫" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "基數", "dashboard-preview": "儀表板預覽", From 34e3c20250577be86dc3994065e4631fa03b9041 Mon Sep 17 00:00:00 2001 From: Eric Shields Date: Thu, 27 Nov 2025 17:19:38 -0800 Subject: [PATCH 25/31] Chore: `tree` is never undefined, so set type to non-optional (#114518) --- .../scopes/selector/ScopesSelectorService.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index 9dea729ff88..ede86a50135 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -46,7 +46,7 @@ export interface ScopesSelectorServiceState { // Simple tree structure for the scopes categories. Each node in a tree has a scopeNodeId which keys the nodes cache // map. - tree: TreeNode | undefined; + tree: TreeNode; } export class ScopesSelectorService extends ScopesServiceBase { @@ -116,9 +116,6 @@ export class ScopesSelectorService extends ScopesServiceBase => { - if (!tree) { - throw new Error('Tree is required'); - } const nodePath = await this.getNodePath(scopeNodeId); const newTree = insertPathNodesIntoTree(tree, nodePath); @@ -133,7 +130,7 @@ export class ScopesSelectorService extends ScopesServiceBase { + const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => { treeNode.expanded = !nodeToToggle.expanded; treeNode.query = ''; }); @@ -152,7 +149,7 @@ export class ScopesSelectorService extends ScopesServiceBase { + const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => { treeNode.expanded = true; treeNode.query = query; }); @@ -209,7 +206,7 @@ export class ScopesSelectorService extends ScopesServiceBase { + const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => { // Set parent query only when filtering within existing children treeNode.children = {}; for (const node of childNodes) { @@ -455,12 +452,12 @@ export class ScopesSelectorService extends ScopesServiceBase { - if (!this.state.tree?.children || Object.keys(this.state.tree?.children).length === 0) { + if (!this.state.tree.children || Object.keys(this.state.tree.children).length === 0) { await this.filterNode('', ''); } // First close all nodes - let newTree = closeNodes(this.state.tree!); + let newTree = closeNodes(this.state.tree); if (this.state.selectedScopes.length && this.state.selectedScopes[0].parentNodeId) { let path = getPathOfNode(this.state.selectedScopes[0].parentNodeId, this.state.nodes); From 646fb2aa35dfb13d033087c6baf1538fa861c735 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 28 Nov 2025 07:47:59 +0200 Subject: [PATCH 26/31] Provisioning: Add source link to provisioned dashboards (#114552) * Provisioning: Add dashboard source link * Fix type * Refactor * Simplify code * more fixes * Extract utils * Switch to object params * Fix types * Move to existing file --- public/app/features/dashboard/api/v1.ts | 23 ++++-- public/app/features/dashboard/api/v2.ts | 7 ++ .../Repository/ResourceTreeView.tsx | 15 +++- .../Shared/PreviewBannerViewPR.test.tsx | 4 +- .../components/Shared/PreviewBannerViewPR.tsx | 10 +-- public/app/features/provisioning/guards.ts | 9 +++ public/app/features/provisioning/utils/git.ts | 57 +++++++------- .../features/provisioning/utils/sourceLink.ts | 75 +++++++++++++++++++ public/locales/en-US/grafana.json | 4 + 9 files changed, 159 insertions(+), 45 deletions(-) create mode 100644 public/app/features/provisioning/utils/sourceLink.ts diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index b8c57653923..9414d3531a6 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -6,21 +6,22 @@ import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks'; import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors'; import { ScopedResourceClient } from 'app/features/apiserver/client'; import { - ResourceClient, - ResourceForCreate, - AnnoKeyMessage, AnnoKeyFolder, AnnoKeyGrantPermissions, - Resource, - DeprecatedInternalId, - AnnoKeyManagerKind, - AnnoKeySourcePath, AnnoKeyManagerAllowsEdits, - ManagerKind, + AnnoKeyManagerKind, + AnnoKeyMessage, + AnnoKeySourcePath, AnnoReloadOnParamsChange, + DeprecatedInternalId, + ManagerKind, + Resource, + ResourceClient, + ResourceForCreate, } from 'app/features/apiserver/types'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { buildSourceLink } from 'app/features/provisioning/utils/sourceLink'; import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; @@ -160,6 +161,12 @@ export class K8sDashboardAPI implements DashboardAPI { result.meta.provisionedExternalId = annotations[AnnoKeySourcePath]; } + // Inject source link for repo-managed dashboards + const sourceLink = await buildSourceLink(annotations); + if (sourceLink) { + result.dashboard.links = [sourceLink, ...(result.dashboard.links || [])]; + } + if (dash.metadata.labels?.[DeprecatedInternalId]) { result.dashboard.id = parseInt(dash.metadata.labels[DeprecatedInternalId], 10); } diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index 33ec2323786..ddf85da7338 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -18,6 +18,7 @@ import { } from 'app/features/apiserver/types'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { buildSourceLink } from 'app/features/provisioning/utils/sourceLink'; import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; @@ -75,6 +76,12 @@ export class K8sDashboardV2API dashboard.metadata.annotations[AnnoKeyFolder] = ''; } + // Inject source link for repo-managed dashboards + const sourceLink = await buildSourceLink(dashboard.metadata.annotations); + if (sourceLink) { + dashboard.spec.links = [sourceLink, ...(dashboard.spec.links || [])]; + } + return dashboard; } catch (e) { const status = getStatusFromError(e); diff --git a/public/app/features/provisioning/Repository/ResourceTreeView.tsx b/public/app/features/provisioning/Repository/ResourceTreeView.tsx index 58282803841..33471a77af6 100644 --- a/public/app/features/provisioning/Repository/ResourceTreeView.tsx +++ b/public/app/features/provisioning/Repository/ResourceTreeView.tsx @@ -140,7 +140,20 @@ export function ResourceTreeView({ repo }: ResourceTreeViewProps) { } const viewLink = getGrafanaLink(item); - const sourceLink = item.hasFile ? getRepoFileUrl(repo.spec, item.path) : undefined; + let sourceLink: string | undefined = undefined; + if (item.hasFile && repo.spec?.type) { + const spec = repo.spec; + const config = spec.github || spec.gitlab || spec.bitbucket; + if (config) { + sourceLink = getRepoFileUrl({ + repoType: spec.type, + url: config.url, + branch: config.branch, + filePath: item.path, + pathPrefix: config.path, + }); + } + } if (!viewLink && !sourceLink) { return null; diff --git a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx index b742368dbce..2b854ad92d5 100644 --- a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx +++ b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.test.tsx @@ -5,7 +5,9 @@ import { textUtil } from '@grafana/data'; import { RepoType } from 'app/features/provisioning/Wizard/types'; import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam'; -import { isValidRepoType, PreviewBannerViewPR } from './PreviewBannerViewPR'; +import { isValidRepoType } from '../../guards'; + +import { PreviewBannerViewPR } from './PreviewBannerViewPR'; jest.mock('@grafana/data', () => ({ ...jest.requireActual('@grafana/data'), diff --git a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx index 15b814a14db..932f7152dac 100644 --- a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx +++ b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx @@ -1,7 +1,8 @@ import { textUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { Alert, Box, Icon, Stack, TextLink } from '@grafana/ui'; -import { RepoTypeDisplay, RepoType } from 'app/features/provisioning/Wizard/types'; +import { RepoTypeDisplay } from 'app/features/provisioning/Wizard/types'; +import { isValidRepoType } from 'app/features/provisioning/guards'; import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam'; import { commonAlertProps } from '../Dashboards/DashboardPreviewBanner'; @@ -121,13 +122,6 @@ export function PreviewBannerViewPR({ prParam, isNewPr, behindBranch, repoUrl, b ); } -export function isValidRepoType(repoType: string | undefined): repoType is RepoType { - if (typeof repoType !== 'string') { - return false; - } - return repoType in RepoTypeDisplay; -} - function showBranchInfo(repoType: string | undefined, branchInfo?: PreviewBranchInfo): boolean { const { targetBranch, configuredBranch, repoBaseUrl } = branchInfo || {}; return repoType !== 'local' && !!targetBranch && !!configuredBranch && !!repoBaseUrl; diff --git a/public/app/features/provisioning/guards.ts b/public/app/features/provisioning/guards.ts index 1ac4e9cfdae..c9a6f16cbe2 100644 --- a/public/app/features/provisioning/guards.ts +++ b/public/app/features/provisioning/guards.ts @@ -1,3 +1,5 @@ +import { RepoType, RepoTypeDisplay } from './Wizard/types'; + export interface HttpError extends Error { status?: number; } @@ -9,3 +11,10 @@ export function isSupportedGitProvider(provider: string): provider is 'github' | export function isHttpError(err: unknown): err is HttpError { return err instanceof Error && 'status' in err; } + +export function isValidRepoType(repoType: string | undefined): repoType is RepoType { + if (typeof repoType !== 'string') { + return false; + } + return repoType in RepoTypeDisplay; +} diff --git a/public/app/features/provisioning/utils/git.ts b/public/app/features/provisioning/utils/git.ts index 4fec609c388..ac16e715a70 100644 --- a/public/app/features/provisioning/utils/git.ts +++ b/public/app/features/provisioning/utils/git.ts @@ -101,51 +101,54 @@ export function getHasTokenInstructions(type: RepoType): type is InstructionAvai return type === 'github' || type === 'gitlab' || type === 'bitbucket'; } -export function getRepoFileUrl(spec?: RepositorySpec, filePath?: string) { - if (!spec || !spec.type || !filePath) { +type GetRepoFileUrlParams = { + repoType: RepoType; + url: string | undefined; + branch?: string | undefined; + filePath: string | undefined; + pathPrefix?: string | null; +}; + +/** + * Build a URL to a specific source file in a repository. + * Only works for git providers (GitHub, GitLab, Bitbucket). + */ +export function getRepoFileUrl({ + repoType, + url, + branch, + filePath, + pathPrefix, +}: GetRepoFileUrlParams): string | undefined { + if (!url || !filePath) { return undefined; } - switch (spec.type) { - case 'github': { - const { url, branch, path } = spec.github ?? {}; - if (!url) { - return undefined; - } - const fullPath = path ? `${path}${filePath}` : filePath; + const effectiveBranch = branch || 'main'; + const fullPath = pathPrefix ? `${pathPrefix}${filePath}` : filePath; + + switch (repoType) { + case 'github': return buildRepoUrl({ baseUrl: url, - branch: branch || 'main', + branch: effectiveBranch, providerSegments: ['blob'], path: fullPath, }); - } - case 'gitlab': { - const { url, branch, path } = spec.gitlab ?? {}; - if (!url) { - return undefined; - } - const fullPath = path ? `${path}${filePath}` : filePath; + case 'gitlab': return buildRepoUrl({ baseUrl: url, - branch: branch || 'main', + branch: effectiveBranch, providerSegments: ['-', 'blob'], path: fullPath, }); - } - case 'bitbucket': { - const { url, branch, path } = spec.bitbucket ?? {}; - if (!url) { - return undefined; - } - const fullPath = path ? `${path}${filePath}` : filePath; + case 'bitbucket': return buildRepoUrl({ baseUrl: url, - branch: branch || 'main', + branch: effectiveBranch, providerSegments: ['src'], path: fullPath, }); - } default: return undefined; } diff --git a/public/app/features/provisioning/utils/sourceLink.ts b/public/app/features/provisioning/utils/sourceLink.ts new file mode 100644 index 00000000000..6018f463eab --- /dev/null +++ b/public/app/features/provisioning/utils/sourceLink.ts @@ -0,0 +1,75 @@ +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { DashboardLink } from '@grafana/schema'; +import { provisioningAPIv0alpha1, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import { + AnnoKeyManagerIdentity, + AnnoKeyManagerKind, + AnnoKeySourcePath, + ManagerKind, + ObjectMeta, +} from 'app/features/apiserver/types'; +import { dispatch } from 'app/store/store'; + +import { RepoTypeDisplay } from '../Wizard/types'; +import { isValidRepoType } from '../guards'; + +import { getHasTokenInstructions, getRepoFileUrl } from './git'; + +/** + * Build a source link for a repo-managed dashboard. + * Returns undefined if the dashboard is not repo-managed or if the repository is not a git provider. + */ +export async function buildSourceLink(annotations: ObjectMeta['annotations']): Promise { + if (!annotations || !config.featureToggles.provisioning || annotations[AnnoKeyManagerKind] !== ManagerKind.Repo) { + return undefined; + } + + const managerIdentity = annotations[AnnoKeyManagerIdentity]; + const sourcePath = annotations[AnnoKeySourcePath]; + if (!managerIdentity || !sourcePath) { + return undefined; + } + + try { + const settingsResult = await dispatch(provisioningAPIv0alpha1.endpoints.getFrontendSettings.initiate()); + const repository = settingsResult.data?.items.find((repo: RepositoryView) => repo.name === managerIdentity); + + if (!repository) { + return undefined; + } + + const repoType = repository.type; + if (!getHasTokenInstructions(repoType) || !isValidRepoType(repoType)) { + return undefined; + } + + const sourceUrl = getRepoFileUrl({ + repoType, + url: repository.url, + branch: repository.branch, + filePath: sourcePath, + pathPrefix: repository.path, + }); + if (!sourceUrl) { + return undefined; + } + + const providerName = RepoTypeDisplay[repoType]; + return { + title: t('dashboard.source-link.title', 'Source ({{provider}})', { provider: providerName }), + type: 'link', + url: sourceUrl, + icon: 'external link', + tooltip: t('dashboard.source-link.tooltip', 'View source file in repository'), + targetBlank: true, + tags: [], + asDropdown: false, + includeVars: false, + keepTime: false, + }; + } catch (e) { + console.warn('Failed to fetch repository info for source link:', e); + return undefined; + } +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a8ed1bdf15c..f2d2366b9cd 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5395,6 +5395,10 @@ "loading-initializing-dashboard": "Loading & initializing dashboard", "title-not-found": "Panel with id {{panelId}} not found" }, + "source-link": { + "title": "Source ({{provider}})", + "tooltip": "View source file in repository" + }, "sub-menu-un-connected": { "aria-label-template-variables": "Template variables" }, From 8e7ba60b9333ea6d2728e58ce7b23947d0df8741 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 28 Nov 2025 10:12:50 +0100 Subject: [PATCH 27/31] Zanzana: Team bindings write APIs (#114493) * Zanzana: Team bindings write APIs * Update pkg/services/authz/zanzana/server/server_mutate_teambindings.go Co-authored-by: Gabriel MABILLE * fix missing import * fix linter --------- Co-authored-by: Gabriel MABILLE --- pkg/services/authz/proto/v1/extention.pb.go | 588 ++++++++++++------ pkg/services/authz/proto/v1/extention.proto | 20 + .../authz/zanzana/server/server_mutate.go | 7 + .../server/server_mutate_teambindings.go | 98 +++ .../server/server_mutate_teambindings_test.go | 74 +++ .../authz/zanzana/server/server_test.go | 4 + 6 files changed, 593 insertions(+), 198 deletions(-) create mode 100644 pkg/services/authz/zanzana/server/server_mutate_teambindings.go create mode 100644 pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go diff --git a/pkg/services/authz/proto/v1/extention.pb.go b/pkg/services/authz/proto/v1/extention.pb.go index 358b6a0df95..41be2aaf0c0 100644 --- a/pkg/services/authz/proto/v1/extention.pb.go +++ b/pkg/services/authz/proto/v1/extention.pb.go @@ -125,6 +125,8 @@ type MutateOperation struct { // *MutateOperation_AddUserOrgRole // *MutateOperation_CreateRoleBinding // *MutateOperation_DeleteRoleBinding + // *MutateOperation_CreateTeamBinding + // *MutateOperation_DeleteTeamBinding Operation isMutateOperation_Operation `protobuf_oneof:"operation"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -248,6 +250,24 @@ func (x *MutateOperation) GetDeleteRoleBinding() *DeleteRoleBindingOperation { return nil } +func (x *MutateOperation) GetCreateTeamBinding() *CreateTeamBindingOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_CreateTeamBinding); ok { + return x.CreateTeamBinding + } + } + return nil +} + +func (x *MutateOperation) GetDeleteTeamBinding() *DeleteTeamBindingOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_DeleteTeamBinding); ok { + return x.DeleteTeamBinding + } + } + return nil +} + type isMutateOperation_Operation interface { isMutateOperation_Operation() } @@ -288,6 +308,14 @@ type MutateOperation_DeleteRoleBinding struct { DeleteRoleBinding *DeleteRoleBindingOperation `protobuf:"bytes,9,opt,name=delete_role_binding,json=deleteRoleBinding,proto3,oneof"` } +type MutateOperation_CreateTeamBinding struct { + CreateTeamBinding *CreateTeamBindingOperation `protobuf:"bytes,10,opt,name=create_team_binding,json=createTeamBinding,proto3,oneof"` +} + +type MutateOperation_DeleteTeamBinding struct { + DeleteTeamBinding *DeleteTeamBindingOperation `protobuf:"bytes,11,opt,name=delete_team_binding,json=deleteTeamBinding,proto3,oneof"` +} + func (*MutateOperation_SetFolderParent) isMutateOperation_Operation() {} func (*MutateOperation_DeleteFolder) isMutateOperation_Operation() {} @@ -306,6 +334,10 @@ func (*MutateOperation_CreateRoleBinding) isMutateOperation_Operation() {} func (*MutateOperation_DeleteRoleBinding) isMutateOperation_Operation() {} +func (*MutateOperation_CreateTeamBinding) isMutateOperation_Operation() {} + +func (*MutateOperation_DeleteTeamBinding) isMutateOperation_Operation() {} + type SetFolderParentOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // UID of the folder @@ -843,6 +875,132 @@ func (x *DeleteRoleBindingOperation) GetRoleName() string { return "" } +type CreateTeamBindingOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // uid of the identity + SubjectName string `protobuf:"bytes,1,opt,name=subject_name,json=subjectName,proto3" json:"subject_name,omitempty"` + // uid of the team + TeamName string `protobuf:"bytes,2,opt,name=team_name,json=teamName,proto3" json:"team_name,omitempty"` + // permission of the identity in the team (admin/member) + Permission string `protobuf:"bytes,3,opt,name=permission,proto3" json:"permission,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTeamBindingOperation) Reset() { + *x = CreateTeamBindingOperation{} + mi := &file_extention_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTeamBindingOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTeamBindingOperation) ProtoMessage() {} + +func (x *CreateTeamBindingOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[12] + 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 CreateTeamBindingOperation.ProtoReflect.Descriptor instead. +func (*CreateTeamBindingOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{12} +} + +func (x *CreateTeamBindingOperation) GetSubjectName() string { + if x != nil { + return x.SubjectName + } + return "" +} + +func (x *CreateTeamBindingOperation) GetTeamName() string { + if x != nil { + return x.TeamName + } + return "" +} + +func (x *CreateTeamBindingOperation) GetPermission() string { + if x != nil { + return x.Permission + } + return "" +} + +type DeleteTeamBindingOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // uid of the identity + SubjectName string `protobuf:"bytes,1,opt,name=subject_name,json=subjectName,proto3" json:"subject_name,omitempty"` + // uid of the team + TeamName string `protobuf:"bytes,2,opt,name=team_name,json=teamName,proto3" json:"team_name,omitempty"` + // permission of the identity in the team (admin/member) + Permission string `protobuf:"bytes,3,opt,name=permission,proto3" json:"permission,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteTeamBindingOperation) Reset() { + *x = DeleteTeamBindingOperation{} + mi := &file_extention_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteTeamBindingOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteTeamBindingOperation) ProtoMessage() {} + +func (x *DeleteTeamBindingOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[13] + 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 DeleteTeamBindingOperation.ProtoReflect.Descriptor instead. +func (*DeleteTeamBindingOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{13} +} + +func (x *DeleteTeamBindingOperation) GetSubjectName() string { + if x != nil { + return x.SubjectName + } + return "" +} + +func (x *DeleteTeamBindingOperation) GetTeamName() string { + if x != nil { + return x.TeamName + } + return "" +} + +func (x *DeleteTeamBindingOperation) GetPermission() string { + if x != nil { + return x.Permission + } + return "" +} + type Resource struct { state protoimpl.MessageState `protogen:"open.v1"` // group of the resource (e.g: "dashboard.grafana.app") @@ -857,7 +1015,7 @@ type Resource struct { func (x *Resource) Reset() { *x = Resource{} - mi := &file_extention_proto_msgTypes[12] + mi := &file_extention_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -869,7 +1027,7 @@ func (x *Resource) String() string { func (*Resource) ProtoMessage() {} func (x *Resource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[12] + mi := &file_extention_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -882,7 +1040,7 @@ func (x *Resource) ProtoReflect() protoreflect.Message { // Deprecated: Use Resource.ProtoReflect.Descriptor instead. func (*Resource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{12} + return file_extention_proto_rawDescGZIP(), []int{14} } func (x *Resource) GetGroup() string { @@ -920,7 +1078,7 @@ type Permission struct { func (x *Permission) Reset() { *x = Permission{} - mi := &file_extention_proto_msgTypes[13] + mi := &file_extention_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -932,7 +1090,7 @@ func (x *Permission) String() string { func (*Permission) ProtoMessage() {} func (x *Permission) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[13] + mi := &file_extention_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -945,7 +1103,7 @@ func (x *Permission) ProtoReflect() protoreflect.Message { // Deprecated: Use Permission.ProtoReflect.Descriptor instead. func (*Permission) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{13} + return file_extention_proto_rawDescGZIP(), []int{15} } func (x *Permission) GetKind() string { @@ -981,7 +1139,7 @@ type TupleKey struct { func (x *TupleKey) Reset() { *x = TupleKey{} - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -993,7 +1151,7 @@ func (x *TupleKey) String() string { func (*TupleKey) ProtoMessage() {} func (x *TupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1006,7 +1164,7 @@ func (x *TupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKey.ProtoReflect.Descriptor instead. func (*TupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{14} + return file_extention_proto_rawDescGZIP(), []int{16} } func (x *TupleKey) GetUser() string { @@ -1047,7 +1205,7 @@ type Tuple struct { func (x *Tuple) Reset() { *x = Tuple{} - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1059,7 +1217,7 @@ func (x *Tuple) String() string { func (*Tuple) ProtoMessage() {} func (x *Tuple) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1072,7 +1230,7 @@ func (x *Tuple) ProtoReflect() protoreflect.Message { // Deprecated: Use Tuple.ProtoReflect.Descriptor instead. func (*Tuple) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{15} + return file_extention_proto_rawDescGZIP(), []int{17} } func (x *Tuple) GetKey() *TupleKey { @@ -1100,7 +1258,7 @@ type TupleKeyWithoutCondition struct { func (x *TupleKeyWithoutCondition) Reset() { *x = TupleKeyWithoutCondition{} - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1112,7 +1270,7 @@ func (x *TupleKeyWithoutCondition) String() string { func (*TupleKeyWithoutCondition) ProtoMessage() {} func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1125,7 +1283,7 @@ func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKeyWithoutCondition.ProtoReflect.Descriptor instead. func (*TupleKeyWithoutCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{16} + return file_extention_proto_rawDescGZIP(), []int{18} } func (x *TupleKeyWithoutCondition) GetUser() string { @@ -1159,7 +1317,7 @@ type RelationshipCondition struct { func (x *RelationshipCondition) Reset() { *x = RelationshipCondition{} - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1171,7 +1329,7 @@ func (x *RelationshipCondition) String() string { func (*RelationshipCondition) ProtoMessage() {} func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1184,7 +1342,7 @@ func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationshipCondition.ProtoReflect.Descriptor instead. func (*RelationshipCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{17} + return file_extention_proto_rawDescGZIP(), []int{19} } func (x *RelationshipCondition) GetName() string { @@ -1213,7 +1371,7 @@ type ReadRequest struct { func (x *ReadRequest) Reset() { *x = ReadRequest{} - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1225,7 +1383,7 @@ func (x *ReadRequest) String() string { func (*ReadRequest) ProtoMessage() {} func (x *ReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1238,7 +1396,7 @@ func (x *ReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. func (*ReadRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{18} + return file_extention_proto_rawDescGZIP(), []int{20} } func (x *ReadRequest) GetNamespace() string { @@ -1280,7 +1438,7 @@ type ReadRequestTupleKey struct { func (x *ReadRequestTupleKey) Reset() { *x = ReadRequestTupleKey{} - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1292,7 +1450,7 @@ func (x *ReadRequestTupleKey) String() string { func (*ReadRequestTupleKey) ProtoMessage() {} func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1305,7 +1463,7 @@ func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequestTupleKey.ProtoReflect.Descriptor instead. func (*ReadRequestTupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{19} + return file_extention_proto_rawDescGZIP(), []int{21} } func (x *ReadRequestTupleKey) GetUser() string { @@ -1339,7 +1497,7 @@ type ReadResponse struct { func (x *ReadResponse) Reset() { *x = ReadResponse{} - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1351,7 +1509,7 @@ func (x *ReadResponse) String() string { func (*ReadResponse) ProtoMessage() {} func (x *ReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1364,7 +1522,7 @@ func (x *ReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadResponse.ProtoReflect.Descriptor instead. func (*ReadResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{20} + return file_extention_proto_rawDescGZIP(), []int{22} } func (x *ReadResponse) GetTuples() []*Tuple { @@ -1390,7 +1548,7 @@ type WriteRequestWrites struct { func (x *WriteRequestWrites) Reset() { *x = WriteRequestWrites{} - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1402,7 +1560,7 @@ func (x *WriteRequestWrites) String() string { func (*WriteRequestWrites) ProtoMessage() {} func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1415,7 +1573,7 @@ func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestWrites.ProtoReflect.Descriptor instead. func (*WriteRequestWrites) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{21} + return file_extention_proto_rawDescGZIP(), []int{23} } func (x *WriteRequestWrites) GetTupleKeys() []*TupleKey { @@ -1434,7 +1592,7 @@ type WriteRequestDeletes struct { func (x *WriteRequestDeletes) Reset() { *x = WriteRequestDeletes{} - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1446,7 +1604,7 @@ func (x *WriteRequestDeletes) String() string { func (*WriteRequestDeletes) ProtoMessage() {} func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1459,7 +1617,7 @@ func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestDeletes.ProtoReflect.Descriptor instead. func (*WriteRequestDeletes) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{22} + return file_extention_proto_rawDescGZIP(), []int{24} } func (x *WriteRequestDeletes) GetTupleKeys() []*TupleKeyWithoutCondition { @@ -1480,7 +1638,7 @@ type WriteRequest struct { func (x *WriteRequest) Reset() { *x = WriteRequest{} - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1492,7 +1650,7 @@ func (x *WriteRequest) String() string { func (*WriteRequest) ProtoMessage() {} func (x *WriteRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1505,7 +1663,7 @@ func (x *WriteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequest.ProtoReflect.Descriptor instead. func (*WriteRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{23} + return file_extention_proto_rawDescGZIP(), []int{25} } func (x *WriteRequest) GetNamespace() string { @@ -1537,7 +1695,7 @@ type WriteResponse struct { func (x *WriteResponse) Reset() { *x = WriteResponse{} - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1549,7 +1707,7 @@ func (x *WriteResponse) String() string { func (*WriteResponse) ProtoMessage() {} func (x *WriteResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1562,7 +1720,7 @@ func (x *WriteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteResponse.ProtoReflect.Descriptor instead. func (*WriteResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{24} + return file_extention_proto_rawDescGZIP(), []int{26} } type BatchCheckRequest struct { @@ -1576,7 +1734,7 @@ type BatchCheckRequest struct { func (x *BatchCheckRequest) Reset() { *x = BatchCheckRequest{} - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1588,7 +1746,7 @@ func (x *BatchCheckRequest) String() string { func (*BatchCheckRequest) ProtoMessage() {} func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1601,7 +1759,7 @@ func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckRequest.ProtoReflect.Descriptor instead. func (*BatchCheckRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{25} + return file_extention_proto_rawDescGZIP(), []int{27} } func (x *BatchCheckRequest) GetSubject() string { @@ -1639,7 +1797,7 @@ type BatchCheckItem struct { func (x *BatchCheckItem) Reset() { *x = BatchCheckItem{} - mi := &file_extention_proto_msgTypes[26] + mi := &file_extention_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1651,7 +1809,7 @@ func (x *BatchCheckItem) String() string { func (*BatchCheckItem) ProtoMessage() {} func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[26] + mi := &file_extention_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1664,7 +1822,7 @@ func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckItem.ProtoReflect.Descriptor instead. func (*BatchCheckItem) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{26} + return file_extention_proto_rawDescGZIP(), []int{28} } func (x *BatchCheckItem) GetVerb() string { @@ -1718,7 +1876,7 @@ type BatchCheckResponse struct { func (x *BatchCheckResponse) Reset() { *x = BatchCheckResponse{} - mi := &file_extention_proto_msgTypes[27] + mi := &file_extention_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1730,7 +1888,7 @@ func (x *BatchCheckResponse) String() string { func (*BatchCheckResponse) ProtoMessage() {} func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[27] + mi := &file_extention_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1743,7 +1901,7 @@ func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckResponse.ProtoReflect.Descriptor instead. func (*BatchCheckResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{27} + return file_extention_proto_rawDescGZIP(), []int{29} } func (x *BatchCheckResponse) GetGroups() map[string]*BatchCheckGroupResource { @@ -1762,7 +1920,7 @@ type BatchCheckGroupResource struct { func (x *BatchCheckGroupResource) Reset() { *x = BatchCheckGroupResource{} - mi := &file_extention_proto_msgTypes[28] + mi := &file_extention_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1774,7 +1932,7 @@ func (x *BatchCheckGroupResource) String() string { func (*BatchCheckGroupResource) ProtoMessage() {} func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[28] + mi := &file_extention_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1787,7 +1945,7 @@ func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckGroupResource.ProtoReflect.Descriptor instead. func (*BatchCheckGroupResource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{28} + return file_extention_proto_rawDescGZIP(), []int{30} } func (x *BatchCheckGroupResource) GetItems() map[string]bool { @@ -1807,7 +1965,7 @@ type QueryRequest struct { func (x *QueryRequest) Reset() { *x = QueryRequest{} - mi := &file_extention_proto_msgTypes[29] + mi := &file_extention_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1819,7 +1977,7 @@ func (x *QueryRequest) String() string { func (*QueryRequest) ProtoMessage() {} func (x *QueryRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[29] + mi := &file_extention_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1832,7 +1990,7 @@ func (x *QueryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead. func (*QueryRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{29} + return file_extention_proto_rawDescGZIP(), []int{31} } func (x *QueryRequest) GetNamespace() string { @@ -1861,7 +2019,7 @@ type QueryResponse struct { func (x *QueryResponse) Reset() { *x = QueryResponse{} - mi := &file_extention_proto_msgTypes[30] + mi := &file_extention_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1873,7 +2031,7 @@ func (x *QueryResponse) String() string { func (*QueryResponse) ProtoMessage() {} func (x *QueryResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[30] + mi := &file_extention_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1886,7 +2044,7 @@ func (x *QueryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryResponse.ProtoReflect.Descriptor instead. func (*QueryResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{30} + return file_extention_proto_rawDescGZIP(), []int{32} } func (x *QueryResponse) GetResult() isQueryResponse_Result { @@ -1927,7 +2085,7 @@ type QueryOperation struct { func (x *QueryOperation) Reset() { *x = QueryOperation{} - mi := &file_extention_proto_msgTypes[31] + mi := &file_extention_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1939,7 +2097,7 @@ func (x *QueryOperation) String() string { func (*QueryOperation) ProtoMessage() {} func (x *QueryOperation) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[31] + mi := &file_extention_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1952,7 +2110,7 @@ func (x *QueryOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryOperation.ProtoReflect.Descriptor instead. func (*QueryOperation) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{31} + return file_extention_proto_rawDescGZIP(), []int{33} } func (x *QueryOperation) GetOperation() isQueryOperation_Operation { @@ -1991,7 +2149,7 @@ type GetFolderParentsQuery struct { func (x *GetFolderParentsQuery) Reset() { *x = GetFolderParentsQuery{} - mi := &file_extention_proto_msgTypes[32] + mi := &file_extention_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2003,7 +2161,7 @@ func (x *GetFolderParentsQuery) String() string { func (*GetFolderParentsQuery) ProtoMessage() {} func (x *GetFolderParentsQuery) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[32] + mi := &file_extention_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2016,7 +2174,7 @@ func (x *GetFolderParentsQuery) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFolderParentsQuery.ProtoReflect.Descriptor instead. func (*GetFolderParentsQuery) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{32} + return file_extention_proto_rawDescGZIP(), []int{34} } func (x *GetFolderParentsQuery) GetFolder() string { @@ -2036,7 +2194,7 @@ type GetFolderParentsResult struct { func (x *GetFolderParentsResult) Reset() { *x = GetFolderParentsResult{} - mi := &file_extention_proto_msgTypes[33] + mi := &file_extention_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2048,7 +2206,7 @@ func (x *GetFolderParentsResult) String() string { func (*GetFolderParentsResult) ProtoMessage() {} func (x *GetFolderParentsResult) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[33] + mi := &file_extention_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2061,7 +2219,7 @@ func (x *GetFolderParentsResult) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFolderParentsResult.ProtoReflect.Descriptor instead. func (*GetFolderParentsResult) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{33} + return file_extention_proto_rawDescGZIP(), []int{35} } func (x *GetFolderParentsResult) GetParentUids() []string { @@ -2090,7 +2248,7 @@ var file_extention_proto_rawDesc = string([]byte{ 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x10, 0x0a, 0x0e, 0x4d, 0x75, 0x74, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xec, 0x06, 0x0a, 0x0f, 0x4d, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb0, 0x08, 0x0a, 0x0f, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x5a, 0x0a, 0x11, 0x73, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, @@ -2144,74 +2302,102 @@ var file_extention_proto_rawDesc = string([]byte{ 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x64, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x42, 0x0b, 0x0a, 0x09, - 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x73, 0x0a, 0x18, 0x53, 0x65, 0x74, - 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x4f, 0x70, 0x65, 0x72, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, - 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, - 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, - 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, - 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x22, 0x70, - 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x4f, 0x70, - 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, - 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, - 0x22, 0x95, 0x01, 0x0a, 0x19, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x38, - 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, - 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, + 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x60, 0x0a, 0x13, + 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x62, 0x69, 0x6e, 0x64, + 0x69, 0x6e, 0x67, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, + 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x60, + 0x0a, 0x13, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x62, 0x69, + 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, + 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, + 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x64, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, + 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x73, 0x0a, + 0x18, 0x53, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, + 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, + 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, + 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, + 0x6e, 0x67, 0x22, 0x70, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x46, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, + 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, + 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, + 0x74, 0x69, 0x6e, 0x67, 0x22, 0x95, 0x01, 0x0a, 0x19, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, + 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, + 0x19, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, - 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x70, 0x65, - 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x19, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, - 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, - 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x12, 0x3e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x22, 0x41, 0x0a, 0x17, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, + 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, + 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, + 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x41, 0x0a, 0x17, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x4f, + 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, + 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, + 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, - 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, - 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, 0x1a, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, - 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, - 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, - 0x9c, 0x01, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, - 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, - 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, - 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, - 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x9c, - 0x01, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, + 0x6f, 0x6c, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x6f, + 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, + 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, + 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, + 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, + 0x6d, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, + 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, + 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, + 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, + 0x65, 0x22, 0x7c, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, + 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, + 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, + 0x7c, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, - 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, - 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, - 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, 0x64, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, 0x64, - 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x50, 0x0a, + 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, + 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, + 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x50, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 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, @@ -2417,7 +2603,7 @@ func file_extention_proto_rawDescGZIP() []byte { return file_extention_proto_rawDescData } -var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 36) +var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 38) var file_extention_proto_goTypes = []any{ (*MutateRequest)(nil), // 0: authz.extention.v1.MutateRequest (*MutateResponse)(nil), // 1: authz.extention.v1.MutateResponse @@ -2431,33 +2617,35 @@ var file_extention_proto_goTypes = []any{ (*DeleteUserOrgRoleOperation)(nil), // 9: authz.extention.v1.DeleteUserOrgRoleOperation (*CreateRoleBindingOperation)(nil), // 10: authz.extention.v1.CreateRoleBindingOperation (*DeleteRoleBindingOperation)(nil), // 11: authz.extention.v1.DeleteRoleBindingOperation - (*Resource)(nil), // 12: authz.extention.v1.Resource - (*Permission)(nil), // 13: authz.extention.v1.Permission - (*TupleKey)(nil), // 14: authz.extention.v1.TupleKey - (*Tuple)(nil), // 15: authz.extention.v1.Tuple - (*TupleKeyWithoutCondition)(nil), // 16: authz.extention.v1.TupleKeyWithoutCondition - (*RelationshipCondition)(nil), // 17: authz.extention.v1.RelationshipCondition - (*ReadRequest)(nil), // 18: authz.extention.v1.ReadRequest - (*ReadRequestTupleKey)(nil), // 19: authz.extention.v1.ReadRequestTupleKey - (*ReadResponse)(nil), // 20: authz.extention.v1.ReadResponse - (*WriteRequestWrites)(nil), // 21: authz.extention.v1.WriteRequestWrites - (*WriteRequestDeletes)(nil), // 22: authz.extention.v1.WriteRequestDeletes - (*WriteRequest)(nil), // 23: authz.extention.v1.WriteRequest - (*WriteResponse)(nil), // 24: authz.extention.v1.WriteResponse - (*BatchCheckRequest)(nil), // 25: authz.extention.v1.BatchCheckRequest - (*BatchCheckItem)(nil), // 26: authz.extention.v1.BatchCheckItem - (*BatchCheckResponse)(nil), // 27: authz.extention.v1.BatchCheckResponse - (*BatchCheckGroupResource)(nil), // 28: authz.extention.v1.BatchCheckGroupResource - (*QueryRequest)(nil), // 29: authz.extention.v1.QueryRequest - (*QueryResponse)(nil), // 30: authz.extention.v1.QueryResponse - (*QueryOperation)(nil), // 31: authz.extention.v1.QueryOperation - (*GetFolderParentsQuery)(nil), // 32: authz.extention.v1.GetFolderParentsQuery - (*GetFolderParentsResult)(nil), // 33: authz.extention.v1.GetFolderParentsResult - nil, // 34: authz.extention.v1.BatchCheckResponse.GroupsEntry - nil, // 35: authz.extention.v1.BatchCheckGroupResource.ItemsEntry - (*timestamppb.Timestamp)(nil), // 36: google.protobuf.Timestamp - (*structpb.Struct)(nil), // 37: google.protobuf.Struct - (*wrapperspb.Int32Value)(nil), // 38: google.protobuf.Int32Value + (*CreateTeamBindingOperation)(nil), // 12: authz.extention.v1.CreateTeamBindingOperation + (*DeleteTeamBindingOperation)(nil), // 13: authz.extention.v1.DeleteTeamBindingOperation + (*Resource)(nil), // 14: authz.extention.v1.Resource + (*Permission)(nil), // 15: authz.extention.v1.Permission + (*TupleKey)(nil), // 16: authz.extention.v1.TupleKey + (*Tuple)(nil), // 17: authz.extention.v1.Tuple + (*TupleKeyWithoutCondition)(nil), // 18: authz.extention.v1.TupleKeyWithoutCondition + (*RelationshipCondition)(nil), // 19: authz.extention.v1.RelationshipCondition + (*ReadRequest)(nil), // 20: authz.extention.v1.ReadRequest + (*ReadRequestTupleKey)(nil), // 21: authz.extention.v1.ReadRequestTupleKey + (*ReadResponse)(nil), // 22: authz.extention.v1.ReadResponse + (*WriteRequestWrites)(nil), // 23: authz.extention.v1.WriteRequestWrites + (*WriteRequestDeletes)(nil), // 24: authz.extention.v1.WriteRequestDeletes + (*WriteRequest)(nil), // 25: authz.extention.v1.WriteRequest + (*WriteResponse)(nil), // 26: authz.extention.v1.WriteResponse + (*BatchCheckRequest)(nil), // 27: authz.extention.v1.BatchCheckRequest + (*BatchCheckItem)(nil), // 28: authz.extention.v1.BatchCheckItem + (*BatchCheckResponse)(nil), // 29: authz.extention.v1.BatchCheckResponse + (*BatchCheckGroupResource)(nil), // 30: authz.extention.v1.BatchCheckGroupResource + (*QueryRequest)(nil), // 31: authz.extention.v1.QueryRequest + (*QueryResponse)(nil), // 32: authz.extention.v1.QueryResponse + (*QueryOperation)(nil), // 33: authz.extention.v1.QueryOperation + (*GetFolderParentsQuery)(nil), // 34: authz.extention.v1.GetFolderParentsQuery + (*GetFolderParentsResult)(nil), // 35: authz.extention.v1.GetFolderParentsResult + nil, // 36: authz.extention.v1.BatchCheckResponse.GroupsEntry + nil, // 37: authz.extention.v1.BatchCheckGroupResource.ItemsEntry + (*timestamppb.Timestamp)(nil), // 38: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 39: google.protobuf.Struct + (*wrapperspb.Int32Value)(nil), // 40: google.protobuf.Int32Value } var file_extention_proto_depIdxs = []int32{ 2, // 0: authz.extention.v1.MutateRequest.operations:type_name -> authz.extention.v1.MutateOperation @@ -2470,43 +2658,45 @@ var file_extention_proto_depIdxs = []int32{ 7, // 7: authz.extention.v1.MutateOperation.add_user_org_role:type_name -> authz.extention.v1.AddUserOrgRoleOperation 10, // 8: authz.extention.v1.MutateOperation.create_role_binding:type_name -> authz.extention.v1.CreateRoleBindingOperation 11, // 9: authz.extention.v1.MutateOperation.delete_role_binding:type_name -> authz.extention.v1.DeleteRoleBindingOperation - 12, // 10: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 13, // 11: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 12, // 12: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 13, // 13: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 17, // 14: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition - 14, // 15: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey - 36, // 16: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp - 37, // 17: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct - 19, // 18: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey - 38, // 19: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value - 15, // 20: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple - 14, // 21: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey - 16, // 22: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition - 21, // 23: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites - 22, // 24: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes - 26, // 25: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem - 34, // 26: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry - 35, // 27: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry - 31, // 28: authz.extention.v1.QueryRequest.operation:type_name -> authz.extention.v1.QueryOperation - 33, // 29: authz.extention.v1.QueryResponse.folder_parents:type_name -> authz.extention.v1.GetFolderParentsResult - 32, // 30: authz.extention.v1.QueryOperation.get_folder_parents:type_name -> authz.extention.v1.GetFolderParentsQuery - 28, // 31: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource - 25, // 32: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest - 18, // 33: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest - 23, // 34: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest - 0, // 35: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest - 29, // 36: authz.extention.v1.AuthzExtentionService.Query:input_type -> authz.extention.v1.QueryRequest - 27, // 37: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse - 20, // 38: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse - 24, // 39: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse - 1, // 40: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse - 30, // 41: authz.extention.v1.AuthzExtentionService.Query:output_type -> authz.extention.v1.QueryResponse - 37, // [37:42] is the sub-list for method output_type - 32, // [32:37] is the sub-list for method input_type - 32, // [32:32] is the sub-list for extension type_name - 32, // [32:32] is the sub-list for extension extendee - 0, // [0:32] is the sub-list for field type_name + 12, // 10: authz.extention.v1.MutateOperation.create_team_binding:type_name -> authz.extention.v1.CreateTeamBindingOperation + 13, // 11: authz.extention.v1.MutateOperation.delete_team_binding:type_name -> authz.extention.v1.DeleteTeamBindingOperation + 14, // 12: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 15, // 13: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 14, // 14: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 15, // 15: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 19, // 16: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition + 16, // 17: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey + 38, // 18: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp + 39, // 19: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct + 21, // 20: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey + 40, // 21: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value + 17, // 22: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple + 16, // 23: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey + 18, // 24: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition + 23, // 25: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites + 24, // 26: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes + 28, // 27: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem + 36, // 28: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry + 37, // 29: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry + 33, // 30: authz.extention.v1.QueryRequest.operation:type_name -> authz.extention.v1.QueryOperation + 35, // 31: authz.extention.v1.QueryResponse.folder_parents:type_name -> authz.extention.v1.GetFolderParentsResult + 34, // 32: authz.extention.v1.QueryOperation.get_folder_parents:type_name -> authz.extention.v1.GetFolderParentsQuery + 30, // 33: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource + 27, // 34: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest + 20, // 35: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest + 25, // 36: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest + 0, // 37: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest + 31, // 38: authz.extention.v1.AuthzExtentionService.Query:input_type -> authz.extention.v1.QueryRequest + 29, // 39: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse + 22, // 40: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse + 26, // 41: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse + 1, // 42: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse + 32, // 43: authz.extention.v1.AuthzExtentionService.Query:output_type -> authz.extention.v1.QueryResponse + 39, // [39:44] is the sub-list for method output_type + 34, // [34:39] is the sub-list for method input_type + 34, // [34:34] is the sub-list for extension type_name + 34, // [34:34] is the sub-list for extension extendee + 0, // [0:34] is the sub-list for field type_name } func init() { file_extention_proto_init() } @@ -2524,11 +2714,13 @@ func file_extention_proto_init() { (*MutateOperation_AddUserOrgRole)(nil), (*MutateOperation_CreateRoleBinding)(nil), (*MutateOperation_DeleteRoleBinding)(nil), + (*MutateOperation_CreateTeamBinding)(nil), + (*MutateOperation_DeleteTeamBinding)(nil), } - file_extention_proto_msgTypes[30].OneofWrappers = []any{ + file_extention_proto_msgTypes[32].OneofWrappers = []any{ (*QueryResponse_FolderParents)(nil), } - file_extention_proto_msgTypes[31].OneofWrappers = []any{ + file_extention_proto_msgTypes[33].OneofWrappers = []any{ (*QueryOperation_GetFolderParents)(nil), } type x struct{} @@ -2537,7 +2729,7 @@ func file_extention_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_extention_proto_rawDesc), len(file_extention_proto_rawDesc)), NumEnums: 0, - NumMessages: 36, + NumMessages: 38, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/services/authz/proto/v1/extention.proto b/pkg/services/authz/proto/v1/extention.proto index 80886ae13b3..4e33f32db21 100644 --- a/pkg/services/authz/proto/v1/extention.proto +++ b/pkg/services/authz/proto/v1/extention.proto @@ -36,6 +36,8 @@ message MutateOperation { AddUserOrgRoleOperation add_user_org_role = 7; CreateRoleBindingOperation create_role_binding = 8; DeleteRoleBindingOperation delete_role_binding = 9; + CreateTeamBindingOperation create_team_binding = 10; + DeleteTeamBindingOperation delete_team_binding = 11; } } @@ -111,6 +113,24 @@ message DeleteRoleBindingOperation { string role_name = 4; } +message CreateTeamBindingOperation { + // uid of the identity + string subject_name = 1; + // uid of the team + string team_name = 2; + // permission of the identity in the team (admin/member) + string permission = 3; +} + +message DeleteTeamBindingOperation { + // uid of the identity + string subject_name = 1; + // uid of the team + string team_name = 2; + // permission of the identity in the team (admin/member) + string permission = 3; +} + message Resource { // group of the resource (e.g: "dashboard.grafana.app") string group = 1; diff --git a/pkg/services/authz/zanzana/server/server_mutate.go b/pkg/services/authz/zanzana/server/server_mutate.go index 45d40df8173..8d3696ceb82 100644 --- a/pkg/services/authz/zanzana/server/server_mutate.go +++ b/pkg/services/authz/zanzana/server/server_mutate.go @@ -16,6 +16,7 @@ const ( OperationGroupPermission OperationGroup = "permission" OperationGroupUserOrgRole OperationGroup = "user_org_role" OperationGroupRoleBinding OperationGroup = "role_binding" + OperationGroupTeamBinding OperationGroup = "team_binding" ) func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) { @@ -68,6 +69,10 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au if err := s.mutateRoleBindings(ctx, storeInf, operations); err != nil { return nil, fmt.Errorf("failed to mutate role bindings: %w", err) } + case OperationGroupTeamBinding: + if err := s.mutateTeamBindings(ctx, storeInf, operations); err != nil { + return nil, fmt.Errorf("failed to mutate team bindings: %w", err) + } default: s.logger.Warn("unsupported operation group", "operationGroup", operationGroup) } @@ -86,6 +91,8 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e return OperationGroupUserOrgRole, nil case *authzextv1.MutateOperation_CreateRoleBinding, *authzextv1.MutateOperation_DeleteRoleBinding: return OperationGroupRoleBinding, nil + case *authzextv1.MutateOperation_CreateTeamBinding, *authzextv1.MutateOperation_DeleteTeamBinding: + return OperationGroupTeamBinding, nil } return OperationGroup(""), errors.New("unsupported mutate operation type") } diff --git a/pkg/services/authz/zanzana/server/server_mutate_teambindings.go b/pkg/services/authz/zanzana/server/server_mutate_teambindings.go new file mode 100644 index 00000000000..81e1c9cb437 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_teambindings.go @@ -0,0 +1,98 @@ +package server + +import ( + "context" + "errors" + "fmt" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func (s *Server) mutateTeamBindings(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error { + ctx, span := s.tracer.Start(ctx, "server.mutateTeamBindings") + defer span.End() + + writeTuples := make([]*openfgav1.TupleKey, 0) + deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0) + + for _, operation := range operations { + switch op := operation.Operation.(type) { + case *authzextv1.MutateOperation_CreateTeamBinding: + tuple, err := s.getTeamBindingTuple(ctx, op.CreateTeamBinding.GetSubjectName(), op.CreateTeamBinding.GetTeamName(), op.CreateTeamBinding.GetPermission()) + if err != nil { + return err + } + writeTuples = append(writeTuples, tuple) + case *authzextv1.MutateOperation_DeleteTeamBinding: + tuple, err := s.getTeamBindingTuple(ctx, op.DeleteTeamBinding.GetSubjectName(), op.DeleteTeamBinding.GetTeamName(), op.DeleteTeamBinding.GetPermission()) + if err != nil { + return err + } + deleteTuple := &openfgav1.TupleKeyWithoutCondition{ + User: tuple.User, + Relation: tuple.Relation, + Object: tuple.Object, + } + deleteTuples = append(deleteTuples, deleteTuple) + default: + s.logger.Debug("unsupported mutate operation", "operation", op) + } + } + + writeReq := &openfgav1.WriteRequest{ + StoreId: store.ID, + AuthorizationModelId: store.ModelID, + } + if len(writeTuples) > 0 { + writeReq.Writes = &openfgav1.WriteRequestWrites{ + TupleKeys: writeTuples, + OnDuplicate: "ignore", + } + } + if len(deleteTuples) > 0 { + writeReq.Deletes = &openfgav1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + OnMissing: "ignore", + } + } + + _, err := s.openfga.Write(ctx, writeReq) + if err != nil { + s.logger.Error("failed to write resource role binding tuples", "error", err) + return err + } + + return nil +} + +func (s *Server) getTeamBindingTuple(ctx context.Context, subject string, team string, permission string) (*openfgav1.TupleKey, error) { + if subject == "" { + return nil, errors.New("subject name cannot be empty") + } + + if team == "" { + return nil, errors.New("team name cannot be empty") + } + + relation := "" + switch permission { + case string(iamv0.TeamBindingTeamPermissionAdmin): + relation = zanzana.RelationTeamAdmin + case string(iamv0.TeamBindingTeamPermissionMember): + relation = zanzana.RelationTeamMember + default: + return nil, fmt.Errorf("unknown team permission '%s', expected member or admin", permission) + } + + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, subject, ""), + Relation: relation, + Object: zanzana.NewTupleEntry(zanzana.TypeTeam, team, ""), + } + + return tuple, nil +} diff --git a/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go b/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go new file mode 100644 index 00000000000..5103b142fc5 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func setupMutateTeamBindings(t *testing.T, srv *Server) *Server { + t.Helper() + + // seed tuples + tuples := []*openfgav1.TupleKey{ + common.NewTuple("user:1", common.RelationTeamMember, "team:foo"), + } + + return setupOpenFGADatabase(t, srv, tuples) +} + +func testMutateTeamBindings(t *testing.T, srv *Server) { + setupMutateTeamBindings(t, srv) + + t.Run("should update user team binding and delete old team binding", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "1", + TeamName: "foo", + Permission: "admin", + }, + }, + }, + { + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "1", + TeamName: "foo", + Permission: "member", + }, + }, + }, + }, + }) + require.NoError(t, err) + + res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationTeamAdmin, + Object: "team:foo", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 1) + require.Equal(t, "user:1", res.Tuples[0].Key.User) + + res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationTeamMember, + Object: "team:foo", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 0) + }) +} diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index 7275fd411af..514930bf2ba 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -140,6 +140,10 @@ func TestIntegrationServer(t *testing.T) { t.Run("test mutate role bindings", func(t *testing.T) { testMutateRoleBindings(t, srv) }) + + t.Run("test mutate team bindings", func(t *testing.T) { + testMutateTeamBindings(t, srv) + }) } func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { From 358d0eb266c6248efd7be2d41146a1d947dae558 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 28 Nov 2025 11:44:58 +0100 Subject: [PATCH 28/31] Zanzana: Role write APIs (#114533) * Zanzana: Role write APIs * Add tests * Update pkg/services/authz/zanzana/server/server_mutate_roles.go Co-authored-by: Gabriel MABILLE * fix func usage --------- Co-authored-by: Gabriel MABILLE --- pkg/services/authz/proto/v1/extention.pb.go | 1046 ++++++++++------- pkg/services/authz/proto/v1/extention.proto | 25 + pkg/services/authz/zanzana/common/tuple.go | 8 + .../authz/zanzana/server/server_mutate.go | 7 + .../zanzana/server/server_mutate_roles.go | 108 ++ .../server/server_mutate_roles_test.go | 77 ++ .../authz/zanzana/server/server_test.go | 4 + 7 files changed, 878 insertions(+), 397 deletions(-) create mode 100644 pkg/services/authz/zanzana/server/server_mutate_roles.go create mode 100644 pkg/services/authz/zanzana/server/server_mutate_roles_test.go diff --git a/pkg/services/authz/proto/v1/extention.pb.go b/pkg/services/authz/proto/v1/extention.pb.go index 41be2aaf0c0..deff13d5edd 100644 --- a/pkg/services/authz/proto/v1/extention.pb.go +++ b/pkg/services/authz/proto/v1/extention.pb.go @@ -127,6 +127,8 @@ type MutateOperation struct { // *MutateOperation_DeleteRoleBinding // *MutateOperation_CreateTeamBinding // *MutateOperation_DeleteTeamBinding + // *MutateOperation_CreateRole + // *MutateOperation_DeleteRole Operation isMutateOperation_Operation `protobuf_oneof:"operation"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -268,6 +270,24 @@ func (x *MutateOperation) GetDeleteTeamBinding() *DeleteTeamBindingOperation { return nil } +func (x *MutateOperation) GetCreateRole() *CreateRoleOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_CreateRole); ok { + return x.CreateRole + } + } + return nil +} + +func (x *MutateOperation) GetDeleteRole() *DeleteRoleOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_DeleteRole); ok { + return x.DeleteRole + } + } + return nil +} + type isMutateOperation_Operation interface { isMutateOperation_Operation() } @@ -316,6 +336,14 @@ type MutateOperation_DeleteTeamBinding struct { DeleteTeamBinding *DeleteTeamBindingOperation `protobuf:"bytes,11,opt,name=delete_team_binding,json=deleteTeamBinding,proto3,oneof"` } +type MutateOperation_CreateRole struct { + CreateRole *CreateRoleOperation `protobuf:"bytes,12,opt,name=create_role,json=createRole,proto3,oneof"` +} + +type MutateOperation_DeleteRole struct { + DeleteRole *DeleteRoleOperation `protobuf:"bytes,13,opt,name=delete_role,json=deleteRole,proto3,oneof"` +} + func (*MutateOperation_SetFolderParent) isMutateOperation_Operation() {} func (*MutateOperation_DeleteFolder) isMutateOperation_Operation() {} @@ -338,6 +366,10 @@ func (*MutateOperation_CreateTeamBinding) isMutateOperation_Operation() {} func (*MutateOperation_DeleteTeamBinding) isMutateOperation_Operation() {} +func (*MutateOperation_CreateRole) isMutateOperation_Operation() {} + +func (*MutateOperation_DeleteRole) isMutateOperation_Operation() {} + type SetFolderParentOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // UID of the folder @@ -1001,6 +1033,184 @@ func (x *DeleteTeamBindingOperation) GetPermission() string { return "" } +type CreateRoleOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // kind of the role (Role/CoreRole/GlobalRole) + RoleKind string `protobuf:"bytes,1,opt,name=role_kind,json=roleKind,proto3" json:"role_kind,omitempty"` + // uid of the role + RoleName string `protobuf:"bytes,2,opt,name=role_name,json=roleName,proto3" json:"role_name,omitempty"` + // permissions of the role + Permissions []*RolePermission `protobuf:"bytes,3,rep,name=permissions,proto3" json:"permissions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateRoleOperation) Reset() { + *x = CreateRoleOperation{} + mi := &file_extention_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateRoleOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateRoleOperation) ProtoMessage() {} + +func (x *CreateRoleOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[14] + 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 CreateRoleOperation.ProtoReflect.Descriptor instead. +func (*CreateRoleOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateRoleOperation) GetRoleKind() string { + if x != nil { + return x.RoleKind + } + return "" +} + +func (x *CreateRoleOperation) GetRoleName() string { + if x != nil { + return x.RoleName + } + return "" +} + +func (x *CreateRoleOperation) GetPermissions() []*RolePermission { + if x != nil { + return x.Permissions + } + return nil +} + +type DeleteRoleOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // kind of the role (Role/CoreRole/GlobalRole) + RoleKind string `protobuf:"bytes,1,opt,name=role_kind,json=roleKind,proto3" json:"role_kind,omitempty"` + // uid of the role + RoleName string `protobuf:"bytes,2,opt,name=role_name,json=roleName,proto3" json:"role_name,omitempty"` + // permissions of the role + Permissions []*RolePermission `protobuf:"bytes,3,rep,name=permissions,proto3" json:"permissions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteRoleOperation) Reset() { + *x = DeleteRoleOperation{} + mi := &file_extention_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteRoleOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteRoleOperation) ProtoMessage() {} + +func (x *DeleteRoleOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[15] + 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 DeleteRoleOperation.ProtoReflect.Descriptor instead. +func (*DeleteRoleOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{15} +} + +func (x *DeleteRoleOperation) GetRoleKind() string { + if x != nil { + return x.RoleKind + } + return "" +} + +func (x *DeleteRoleOperation) GetRoleName() string { + if x != nil { + return x.RoleName + } + return "" +} + +func (x *DeleteRoleOperation) GetPermissions() []*RolePermission { + if x != nil { + return x.Permissions + } + return nil +} + +type RolePermission struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + Scope string `protobuf:"bytes,2,opt,name=scope,proto3" json:"scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RolePermission) Reset() { + *x = RolePermission{} + mi := &file_extention_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RolePermission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RolePermission) ProtoMessage() {} + +func (x *RolePermission) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[16] + 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 RolePermission.ProtoReflect.Descriptor instead. +func (*RolePermission) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{16} +} + +func (x *RolePermission) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *RolePermission) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + type Resource struct { state protoimpl.MessageState `protogen:"open.v1"` // group of the resource (e.g: "dashboard.grafana.app") @@ -1015,7 +1225,7 @@ type Resource struct { func (x *Resource) Reset() { *x = Resource{} - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1027,7 +1237,7 @@ func (x *Resource) String() string { func (*Resource) ProtoMessage() {} func (x *Resource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1040,7 +1250,7 @@ func (x *Resource) ProtoReflect() protoreflect.Message { // Deprecated: Use Resource.ProtoReflect.Descriptor instead. func (*Resource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{14} + return file_extention_proto_rawDescGZIP(), []int{17} } func (x *Resource) GetGroup() string { @@ -1078,7 +1288,7 @@ type Permission struct { func (x *Permission) Reset() { *x = Permission{} - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1090,7 +1300,7 @@ func (x *Permission) String() string { func (*Permission) ProtoMessage() {} func (x *Permission) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1103,7 +1313,7 @@ func (x *Permission) ProtoReflect() protoreflect.Message { // Deprecated: Use Permission.ProtoReflect.Descriptor instead. func (*Permission) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{15} + return file_extention_proto_rawDescGZIP(), []int{18} } func (x *Permission) GetKind() string { @@ -1139,7 +1349,7 @@ type TupleKey struct { func (x *TupleKey) Reset() { *x = TupleKey{} - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1151,7 +1361,7 @@ func (x *TupleKey) String() string { func (*TupleKey) ProtoMessage() {} func (x *TupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1164,7 +1374,7 @@ func (x *TupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKey.ProtoReflect.Descriptor instead. func (*TupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{16} + return file_extention_proto_rawDescGZIP(), []int{19} } func (x *TupleKey) GetUser() string { @@ -1205,7 +1415,7 @@ type Tuple struct { func (x *Tuple) Reset() { *x = Tuple{} - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1217,7 +1427,7 @@ func (x *Tuple) String() string { func (*Tuple) ProtoMessage() {} func (x *Tuple) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1230,7 +1440,7 @@ func (x *Tuple) ProtoReflect() protoreflect.Message { // Deprecated: Use Tuple.ProtoReflect.Descriptor instead. func (*Tuple) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{17} + return file_extention_proto_rawDescGZIP(), []int{20} } func (x *Tuple) GetKey() *TupleKey { @@ -1258,7 +1468,7 @@ type TupleKeyWithoutCondition struct { func (x *TupleKeyWithoutCondition) Reset() { *x = TupleKeyWithoutCondition{} - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1270,7 +1480,7 @@ func (x *TupleKeyWithoutCondition) String() string { func (*TupleKeyWithoutCondition) ProtoMessage() {} func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1283,7 +1493,7 @@ func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKeyWithoutCondition.ProtoReflect.Descriptor instead. func (*TupleKeyWithoutCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{18} + return file_extention_proto_rawDescGZIP(), []int{21} } func (x *TupleKeyWithoutCondition) GetUser() string { @@ -1317,7 +1527,7 @@ type RelationshipCondition struct { func (x *RelationshipCondition) Reset() { *x = RelationshipCondition{} - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1329,7 +1539,7 @@ func (x *RelationshipCondition) String() string { func (*RelationshipCondition) ProtoMessage() {} func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1342,7 +1552,7 @@ func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationshipCondition.ProtoReflect.Descriptor instead. func (*RelationshipCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{19} + return file_extention_proto_rawDescGZIP(), []int{22} } func (x *RelationshipCondition) GetName() string { @@ -1371,7 +1581,7 @@ type ReadRequest struct { func (x *ReadRequest) Reset() { *x = ReadRequest{} - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1383,7 +1593,7 @@ func (x *ReadRequest) String() string { func (*ReadRequest) ProtoMessage() {} func (x *ReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1396,7 +1606,7 @@ func (x *ReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. func (*ReadRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{20} + return file_extention_proto_rawDescGZIP(), []int{23} } func (x *ReadRequest) GetNamespace() string { @@ -1438,7 +1648,7 @@ type ReadRequestTupleKey struct { func (x *ReadRequestTupleKey) Reset() { *x = ReadRequestTupleKey{} - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1450,7 +1660,7 @@ func (x *ReadRequestTupleKey) String() string { func (*ReadRequestTupleKey) ProtoMessage() {} func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1463,7 +1673,7 @@ func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequestTupleKey.ProtoReflect.Descriptor instead. func (*ReadRequestTupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{21} + return file_extention_proto_rawDescGZIP(), []int{24} } func (x *ReadRequestTupleKey) GetUser() string { @@ -1497,7 +1707,7 @@ type ReadResponse struct { func (x *ReadResponse) Reset() { *x = ReadResponse{} - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1509,7 +1719,7 @@ func (x *ReadResponse) String() string { func (*ReadResponse) ProtoMessage() {} func (x *ReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1522,7 +1732,7 @@ func (x *ReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadResponse.ProtoReflect.Descriptor instead. func (*ReadResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{22} + return file_extention_proto_rawDescGZIP(), []int{25} } func (x *ReadResponse) GetTuples() []*Tuple { @@ -1548,7 +1758,7 @@ type WriteRequestWrites struct { func (x *WriteRequestWrites) Reset() { *x = WriteRequestWrites{} - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1560,7 +1770,7 @@ func (x *WriteRequestWrites) String() string { func (*WriteRequestWrites) ProtoMessage() {} func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1573,7 +1783,7 @@ func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestWrites.ProtoReflect.Descriptor instead. func (*WriteRequestWrites) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{23} + return file_extention_proto_rawDescGZIP(), []int{26} } func (x *WriteRequestWrites) GetTupleKeys() []*TupleKey { @@ -1592,7 +1802,7 @@ type WriteRequestDeletes struct { func (x *WriteRequestDeletes) Reset() { *x = WriteRequestDeletes{} - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1604,7 +1814,7 @@ func (x *WriteRequestDeletes) String() string { func (*WriteRequestDeletes) ProtoMessage() {} func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1617,7 +1827,7 @@ func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestDeletes.ProtoReflect.Descriptor instead. func (*WriteRequestDeletes) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{24} + return file_extention_proto_rawDescGZIP(), []int{27} } func (x *WriteRequestDeletes) GetTupleKeys() []*TupleKeyWithoutCondition { @@ -1638,7 +1848,7 @@ type WriteRequest struct { func (x *WriteRequest) Reset() { *x = WriteRequest{} - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1650,7 +1860,7 @@ func (x *WriteRequest) String() string { func (*WriteRequest) ProtoMessage() {} func (x *WriteRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1663,7 +1873,7 @@ func (x *WriteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequest.ProtoReflect.Descriptor instead. func (*WriteRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{25} + return file_extention_proto_rawDescGZIP(), []int{28} } func (x *WriteRequest) GetNamespace() string { @@ -1695,7 +1905,7 @@ type WriteResponse struct { func (x *WriteResponse) Reset() { *x = WriteResponse{} - mi := &file_extention_proto_msgTypes[26] + mi := &file_extention_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1707,7 +1917,7 @@ func (x *WriteResponse) String() string { func (*WriteResponse) ProtoMessage() {} func (x *WriteResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[26] + mi := &file_extention_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1720,7 +1930,7 @@ func (x *WriteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteResponse.ProtoReflect.Descriptor instead. func (*WriteResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{26} + return file_extention_proto_rawDescGZIP(), []int{29} } type BatchCheckRequest struct { @@ -1734,7 +1944,7 @@ type BatchCheckRequest struct { func (x *BatchCheckRequest) Reset() { *x = BatchCheckRequest{} - mi := &file_extention_proto_msgTypes[27] + mi := &file_extention_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1746,7 +1956,7 @@ func (x *BatchCheckRequest) String() string { func (*BatchCheckRequest) ProtoMessage() {} func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[27] + mi := &file_extention_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1759,7 +1969,7 @@ func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckRequest.ProtoReflect.Descriptor instead. func (*BatchCheckRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{27} + return file_extention_proto_rawDescGZIP(), []int{30} } func (x *BatchCheckRequest) GetSubject() string { @@ -1797,7 +2007,7 @@ type BatchCheckItem struct { func (x *BatchCheckItem) Reset() { *x = BatchCheckItem{} - mi := &file_extention_proto_msgTypes[28] + mi := &file_extention_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1809,7 +2019,7 @@ func (x *BatchCheckItem) String() string { func (*BatchCheckItem) ProtoMessage() {} func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[28] + mi := &file_extention_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1822,7 +2032,7 @@ func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckItem.ProtoReflect.Descriptor instead. func (*BatchCheckItem) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{28} + return file_extention_proto_rawDescGZIP(), []int{31} } func (x *BatchCheckItem) GetVerb() string { @@ -1876,7 +2086,7 @@ type BatchCheckResponse struct { func (x *BatchCheckResponse) Reset() { *x = BatchCheckResponse{} - mi := &file_extention_proto_msgTypes[29] + mi := &file_extention_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1888,7 +2098,7 @@ func (x *BatchCheckResponse) String() string { func (*BatchCheckResponse) ProtoMessage() {} func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[29] + mi := &file_extention_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1901,7 +2111,7 @@ func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckResponse.ProtoReflect.Descriptor instead. func (*BatchCheckResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{29} + return file_extention_proto_rawDescGZIP(), []int{32} } func (x *BatchCheckResponse) GetGroups() map[string]*BatchCheckGroupResource { @@ -1920,7 +2130,7 @@ type BatchCheckGroupResource struct { func (x *BatchCheckGroupResource) Reset() { *x = BatchCheckGroupResource{} - mi := &file_extention_proto_msgTypes[30] + mi := &file_extention_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1932,7 +2142,7 @@ func (x *BatchCheckGroupResource) String() string { func (*BatchCheckGroupResource) ProtoMessage() {} func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[30] + mi := &file_extention_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1945,7 +2155,7 @@ func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckGroupResource.ProtoReflect.Descriptor instead. func (*BatchCheckGroupResource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{30} + return file_extention_proto_rawDescGZIP(), []int{33} } func (x *BatchCheckGroupResource) GetItems() map[string]bool { @@ -1965,7 +2175,7 @@ type QueryRequest struct { func (x *QueryRequest) Reset() { *x = QueryRequest{} - mi := &file_extention_proto_msgTypes[31] + mi := &file_extention_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1977,7 +2187,7 @@ func (x *QueryRequest) String() string { func (*QueryRequest) ProtoMessage() {} func (x *QueryRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[31] + mi := &file_extention_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1990,7 +2200,7 @@ func (x *QueryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead. func (*QueryRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{31} + return file_extention_proto_rawDescGZIP(), []int{34} } func (x *QueryRequest) GetNamespace() string { @@ -2019,7 +2229,7 @@ type QueryResponse struct { func (x *QueryResponse) Reset() { *x = QueryResponse{} - mi := &file_extention_proto_msgTypes[32] + mi := &file_extention_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2031,7 +2241,7 @@ func (x *QueryResponse) String() string { func (*QueryResponse) ProtoMessage() {} func (x *QueryResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[32] + mi := &file_extention_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2044,7 +2254,7 @@ func (x *QueryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryResponse.ProtoReflect.Descriptor instead. func (*QueryResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{32} + return file_extention_proto_rawDescGZIP(), []int{35} } func (x *QueryResponse) GetResult() isQueryResponse_Result { @@ -2085,7 +2295,7 @@ type QueryOperation struct { func (x *QueryOperation) Reset() { *x = QueryOperation{} - mi := &file_extention_proto_msgTypes[33] + mi := &file_extention_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2097,7 +2307,7 @@ func (x *QueryOperation) String() string { func (*QueryOperation) ProtoMessage() {} func (x *QueryOperation) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[33] + mi := &file_extention_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2110,7 +2320,7 @@ func (x *QueryOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryOperation.ProtoReflect.Descriptor instead. func (*QueryOperation) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{33} + return file_extention_proto_rawDescGZIP(), []int{36} } func (x *QueryOperation) GetOperation() isQueryOperation_Operation { @@ -2149,7 +2359,7 @@ type GetFolderParentsQuery struct { func (x *GetFolderParentsQuery) Reset() { *x = GetFolderParentsQuery{} - mi := &file_extention_proto_msgTypes[34] + mi := &file_extention_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2161,7 +2371,7 @@ func (x *GetFolderParentsQuery) String() string { func (*GetFolderParentsQuery) ProtoMessage() {} func (x *GetFolderParentsQuery) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[34] + mi := &file_extention_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2174,7 +2384,7 @@ func (x *GetFolderParentsQuery) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFolderParentsQuery.ProtoReflect.Descriptor instead. func (*GetFolderParentsQuery) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{34} + return file_extention_proto_rawDescGZIP(), []int{37} } func (x *GetFolderParentsQuery) GetFolder() string { @@ -2194,7 +2404,7 @@ type GetFolderParentsResult struct { func (x *GetFolderParentsResult) Reset() { *x = GetFolderParentsResult{} - mi := &file_extention_proto_msgTypes[35] + mi := &file_extention_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2206,7 +2416,7 @@ func (x *GetFolderParentsResult) String() string { func (*GetFolderParentsResult) ProtoMessage() {} func (x *GetFolderParentsResult) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[35] + mi := &file_extention_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2219,7 +2429,7 @@ func (x *GetFolderParentsResult) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFolderParentsResult.ProtoReflect.Descriptor instead. func (*GetFolderParentsResult) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{35} + return file_extention_proto_rawDescGZIP(), []int{38} } func (x *GetFolderParentsResult) GetParentUids() []string { @@ -2248,7 +2458,7 @@ var file_extention_proto_rawDesc = string([]byte{ 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x10, 0x0a, 0x0e, 0x4d, 0x75, 0x74, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb0, 0x08, 0x0a, 0x0f, 0x4d, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xc8, 0x09, 0x0a, 0x0f, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x5a, 0x0a, 0x11, 0x73, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, @@ -2315,32 +2525,32 @@ var file_extention_proto_rawDesc = string([]byte{ 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, - 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x73, 0x0a, - 0x18, 0x53, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, - 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, - 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, - 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, - 0x6e, 0x67, 0x22, 0x70, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x46, 0x6f, 0x6c, 0x64, - 0x65, 0x72, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, - 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, - 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, - 0x74, 0x69, 0x6e, 0x67, 0x22, 0x95, 0x01, 0x0a, 0x19, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, - 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, - 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, - 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, - 0x19, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, + 0x12, 0x4a, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, + 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, + 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x4a, 0x0a, 0x0b, + 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, + 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x0a, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x73, 0x0a, 0x18, 0x53, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, + 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, + 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, 0x69, 0x73, + 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x22, 0x70, 0x0a, 0x15, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x70, + 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, + 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x65, 0x78, + 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x22, 0x95, 0x01, 0x0a, + 0x19, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, @@ -2349,246 +2559,279 @@ var file_extention_proto_rawDesc = string([]byte{ 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x41, 0x0a, 0x17, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x4f, - 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, - 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, - 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, - 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, - 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, - 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, - 0x6f, 0x6c, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x6f, - 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, - 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, - 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, - 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, - 0x6d, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, - 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, - 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, - 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, - 0x65, 0x22, 0x7c, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, - 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, - 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, - 0x7c, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, - 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, - 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, + 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x19, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, + 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x38, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x0a, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x41, 0x0a, 0x17, + 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, + 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, + 0x44, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, + 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, + 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, + 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x44, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, + 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, + 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, - 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, - 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x50, 0x0a, - 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 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, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, - 0x48, 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, - 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, - 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, 0x22, 0x9b, 0x01, 0x0a, 0x08, 0x54, 0x75, - 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x47, - 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, - 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x63, 0x6f, - 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x05, 0x54, 0x75, 0x70, 0x6c, 0x65, - 0x12, 0x2e, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, - 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x62, 0x0a, 0x18, 0x54, 0x75, - 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x5e, - 0x0a, 0x15, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, - 0x6e, 0x64, 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, 0x31, 0x0a, 0x07, 0x63, - 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, - 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0xda, - 0x01, 0x0a, 0x0b, 0x52, 0x65, 0x61, 0x64, 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, 0x44, 0x0a, 0x09, - 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, - 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, - 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x33, 0x32, 0x56, 0x61, 0x6c, - 0x75, 0x65, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2d, 0x0a, 0x12, - 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, - 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x5d, 0x0a, 0x13, 0x52, - 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, - 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x70, 0x0a, 0x0c, 0x52, 0x65, - 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, 0x74, 0x75, - 0x70, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, - 0x54, 0x75, 0x70, 0x6c, 0x65, 0x52, 0x06, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, - 0x12, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, - 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x51, 0x0a, 0x12, - 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, - 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, - 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, - 0x65, 0x4b, 0x65, 0x79, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, 0x22, - 0x62, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, - 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, - 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, - 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, - 0x65, 0x79, 0x73, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x57, 0x72, 0x69, 0x74, 0x65, 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, 0x3e, 0x0a, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, 0x52, 0x06, 0x77, 0x72, 0x69, 0x74, - 0x65, 0x73, 0x12, 0x41, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x52, 0x07, 0x64, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x73, 0x22, 0x0f, 0x0a, 0x0d, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x85, 0x01, 0x0a, 0x11, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, - 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, - 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 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, 0x38, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, - 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0xa4, - 0x01, 0x0a, 0x0e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, - 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x76, 0x65, 0x72, 0x62, 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, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, - 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, - 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, - 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0xc8, 0x01, 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, - 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x06, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x61, + 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, + 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, + 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, + 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, + 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x7c, 0x0a, 0x1a, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, + 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, + 0x61, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, + 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7c, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x61, 0x6d, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x61, + 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, + 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, + 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, + 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x44, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, - 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x1a, 0x66, 0x0a, 0x0b, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x41, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, - 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, - 0x22, 0xa1, 0x01, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, - 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, 0x05, - 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x36, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, - 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, 0x74, - 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x22, 0x6e, 0x0a, 0x0c, 0x51, 0x75, 0x65, 0x72, 0x79, 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, 0x40, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, - 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, - 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x6e, 0x0a, 0x0d, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, - 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, - 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0d, 0x66, 0x6f, 0x6c, - 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, - 0x73, 0x75, 0x6c, 0x74, 0x22, 0x78, 0x0a, 0x0e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x65, - 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x59, 0x0a, 0x12, 0x67, 0x65, 0x74, 0x5f, 0x66, 0x6f, - 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, - 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x51, 0x75, 0x65, 0x72, 0x79, 0x48, 0x00, 0x52, - 0x10, 0x67, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, - 0x73, 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x2f, - 0x0a, 0x15, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, - 0x74, 0x73, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, - 0x39, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, - 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x61, 0x72, - 0x65, 0x6e, 0x74, 0x5f, 0x75, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, - 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x55, 0x69, 0x64, 0x73, 0x32, 0xac, 0x03, 0x0a, 0x15, 0x41, - 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0a, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, - 0x63, 0x6b, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, - 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, - 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x31, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x95, 0x01, + 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, + 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, + 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x44, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x50, 0x65, + 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x3e, 0x0a, 0x0e, 0x52, 0x6f, 0x6c, 0x65, 0x50, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x73, 0x63, 0x6f, 0x70, 0x65, 0x22, 0x50, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 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, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x48, 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, + 0x62, 0x22, 0x9b, 0x01, 0x0a, 0x08, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, + 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, + 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x47, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, - 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, + 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, + 0x71, 0x0a, 0x05, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x12, 0x2e, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, + 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x22, 0x62, 0x0a, 0x18, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, + 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, + 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, + 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x5e, 0x0a, 0x15, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, 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, 0x31, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x07, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0xda, 0x01, 0x0a, 0x0b, 0x52, 0x65, 0x61, 0x64, 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, 0x44, 0x0a, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, + 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, + 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, + 0x52, 0x08, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x70, 0x61, + 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x49, 0x6e, 0x74, 0x33, 0x32, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, + 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x22, 0x5d, 0x0a, 0x13, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, + 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x22, 0x70, 0x0a, 0x0c, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x52, 0x06, 0x74, + 0x75, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x51, 0x0a, 0x12, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x0a, 0x74, 0x75, + 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, + 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x09, 0x74, 0x75, + 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, 0x22, 0x62, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x12, 0x4b, + 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, + 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, 0x22, 0xaf, 0x01, 0x0a, 0x0c, + 0x57, 0x72, 0x69, 0x74, 0x65, 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, 0x3e, 0x0a, 0x06, 0x77, 0x72, + 0x69, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, - 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, - 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, - 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, - 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, - 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, - 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x51, - 0x75, 0x65, 0x72, 0x79, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, - 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 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, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x73, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, + 0x65, 0x73, 0x52, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, 0x41, 0x0a, 0x07, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, + 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x73, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x22, 0x0f, 0x0a, + 0x0d, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x85, + 0x01, 0x0a, 0x11, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 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, 0x38, 0x0a, 0x05, + 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, + 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x52, + 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0xa4, 0x01, 0x0a, 0x0e, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, + 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, 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, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0xc8, 0x01, + 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x1a, 0x66, 0x0a, 0x0b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x41, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xa1, 0x01, 0x0a, 0x17, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x36, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x69, 0x74, 0x65, + 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x6e, 0x0a, 0x0c, + 0x51, 0x75, 0x65, 0x72, 0x79, 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, 0x40, 0x0a, 0x09, 0x6f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, + 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x6e, 0x0a, 0x0d, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, + 0x0e, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, + 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x48, 0x00, 0x52, 0x0d, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, + 0x74, 0x73, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x78, 0x0a, 0x0e, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x59, + 0x0a, 0x12, 0x67, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, + 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, + 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x48, 0x00, 0x52, 0x10, 0x67, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x2f, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, + 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, + 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x39, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x46, 0x6f, + 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x75, 0x69, 0x64, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x55, 0x69, + 0x64, 0x73, 0x32, 0xac, 0x03, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0a, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, + 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, + 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, + 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x20, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, + 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, + 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x42, 0x38, 0x5a, 0x36, 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, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, }) var ( @@ -2603,7 +2846,7 @@ func file_extention_proto_rawDescGZIP() []byte { return file_extention_proto_rawDescData } -var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 38) +var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 41) var file_extention_proto_goTypes = []any{ (*MutateRequest)(nil), // 0: authz.extention.v1.MutateRequest (*MutateResponse)(nil), // 1: authz.extention.v1.MutateResponse @@ -2619,33 +2862,36 @@ var file_extention_proto_goTypes = []any{ (*DeleteRoleBindingOperation)(nil), // 11: authz.extention.v1.DeleteRoleBindingOperation (*CreateTeamBindingOperation)(nil), // 12: authz.extention.v1.CreateTeamBindingOperation (*DeleteTeamBindingOperation)(nil), // 13: authz.extention.v1.DeleteTeamBindingOperation - (*Resource)(nil), // 14: authz.extention.v1.Resource - (*Permission)(nil), // 15: authz.extention.v1.Permission - (*TupleKey)(nil), // 16: authz.extention.v1.TupleKey - (*Tuple)(nil), // 17: authz.extention.v1.Tuple - (*TupleKeyWithoutCondition)(nil), // 18: authz.extention.v1.TupleKeyWithoutCondition - (*RelationshipCondition)(nil), // 19: authz.extention.v1.RelationshipCondition - (*ReadRequest)(nil), // 20: authz.extention.v1.ReadRequest - (*ReadRequestTupleKey)(nil), // 21: authz.extention.v1.ReadRequestTupleKey - (*ReadResponse)(nil), // 22: authz.extention.v1.ReadResponse - (*WriteRequestWrites)(nil), // 23: authz.extention.v1.WriteRequestWrites - (*WriteRequestDeletes)(nil), // 24: authz.extention.v1.WriteRequestDeletes - (*WriteRequest)(nil), // 25: authz.extention.v1.WriteRequest - (*WriteResponse)(nil), // 26: authz.extention.v1.WriteResponse - (*BatchCheckRequest)(nil), // 27: authz.extention.v1.BatchCheckRequest - (*BatchCheckItem)(nil), // 28: authz.extention.v1.BatchCheckItem - (*BatchCheckResponse)(nil), // 29: authz.extention.v1.BatchCheckResponse - (*BatchCheckGroupResource)(nil), // 30: authz.extention.v1.BatchCheckGroupResource - (*QueryRequest)(nil), // 31: authz.extention.v1.QueryRequest - (*QueryResponse)(nil), // 32: authz.extention.v1.QueryResponse - (*QueryOperation)(nil), // 33: authz.extention.v1.QueryOperation - (*GetFolderParentsQuery)(nil), // 34: authz.extention.v1.GetFolderParentsQuery - (*GetFolderParentsResult)(nil), // 35: authz.extention.v1.GetFolderParentsResult - nil, // 36: authz.extention.v1.BatchCheckResponse.GroupsEntry - nil, // 37: authz.extention.v1.BatchCheckGroupResource.ItemsEntry - (*timestamppb.Timestamp)(nil), // 38: google.protobuf.Timestamp - (*structpb.Struct)(nil), // 39: google.protobuf.Struct - (*wrapperspb.Int32Value)(nil), // 40: google.protobuf.Int32Value + (*CreateRoleOperation)(nil), // 14: authz.extention.v1.CreateRoleOperation + (*DeleteRoleOperation)(nil), // 15: authz.extention.v1.DeleteRoleOperation + (*RolePermission)(nil), // 16: authz.extention.v1.RolePermission + (*Resource)(nil), // 17: authz.extention.v1.Resource + (*Permission)(nil), // 18: authz.extention.v1.Permission + (*TupleKey)(nil), // 19: authz.extention.v1.TupleKey + (*Tuple)(nil), // 20: authz.extention.v1.Tuple + (*TupleKeyWithoutCondition)(nil), // 21: authz.extention.v1.TupleKeyWithoutCondition + (*RelationshipCondition)(nil), // 22: authz.extention.v1.RelationshipCondition + (*ReadRequest)(nil), // 23: authz.extention.v1.ReadRequest + (*ReadRequestTupleKey)(nil), // 24: authz.extention.v1.ReadRequestTupleKey + (*ReadResponse)(nil), // 25: authz.extention.v1.ReadResponse + (*WriteRequestWrites)(nil), // 26: authz.extention.v1.WriteRequestWrites + (*WriteRequestDeletes)(nil), // 27: authz.extention.v1.WriteRequestDeletes + (*WriteRequest)(nil), // 28: authz.extention.v1.WriteRequest + (*WriteResponse)(nil), // 29: authz.extention.v1.WriteResponse + (*BatchCheckRequest)(nil), // 30: authz.extention.v1.BatchCheckRequest + (*BatchCheckItem)(nil), // 31: authz.extention.v1.BatchCheckItem + (*BatchCheckResponse)(nil), // 32: authz.extention.v1.BatchCheckResponse + (*BatchCheckGroupResource)(nil), // 33: authz.extention.v1.BatchCheckGroupResource + (*QueryRequest)(nil), // 34: authz.extention.v1.QueryRequest + (*QueryResponse)(nil), // 35: authz.extention.v1.QueryResponse + (*QueryOperation)(nil), // 36: authz.extention.v1.QueryOperation + (*GetFolderParentsQuery)(nil), // 37: authz.extention.v1.GetFolderParentsQuery + (*GetFolderParentsResult)(nil), // 38: authz.extention.v1.GetFolderParentsResult + nil, // 39: authz.extention.v1.BatchCheckResponse.GroupsEntry + nil, // 40: authz.extention.v1.BatchCheckGroupResource.ItemsEntry + (*timestamppb.Timestamp)(nil), // 41: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 42: google.protobuf.Struct + (*wrapperspb.Int32Value)(nil), // 43: google.protobuf.Int32Value } var file_extention_proto_depIdxs = []int32{ 2, // 0: authz.extention.v1.MutateRequest.operations:type_name -> authz.extention.v1.MutateOperation @@ -2660,43 +2906,47 @@ var file_extention_proto_depIdxs = []int32{ 11, // 9: authz.extention.v1.MutateOperation.delete_role_binding:type_name -> authz.extention.v1.DeleteRoleBindingOperation 12, // 10: authz.extention.v1.MutateOperation.create_team_binding:type_name -> authz.extention.v1.CreateTeamBindingOperation 13, // 11: authz.extention.v1.MutateOperation.delete_team_binding:type_name -> authz.extention.v1.DeleteTeamBindingOperation - 14, // 12: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 15, // 13: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 14, // 14: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 15, // 15: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 19, // 16: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition - 16, // 17: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey - 38, // 18: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp - 39, // 19: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct - 21, // 20: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey - 40, // 21: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value - 17, // 22: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple - 16, // 23: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey - 18, // 24: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition - 23, // 25: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites - 24, // 26: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes - 28, // 27: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem - 36, // 28: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry - 37, // 29: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry - 33, // 30: authz.extention.v1.QueryRequest.operation:type_name -> authz.extention.v1.QueryOperation - 35, // 31: authz.extention.v1.QueryResponse.folder_parents:type_name -> authz.extention.v1.GetFolderParentsResult - 34, // 32: authz.extention.v1.QueryOperation.get_folder_parents:type_name -> authz.extention.v1.GetFolderParentsQuery - 30, // 33: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource - 27, // 34: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest - 20, // 35: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest - 25, // 36: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest - 0, // 37: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest - 31, // 38: authz.extention.v1.AuthzExtentionService.Query:input_type -> authz.extention.v1.QueryRequest - 29, // 39: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse - 22, // 40: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse - 26, // 41: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse - 1, // 42: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse - 32, // 43: authz.extention.v1.AuthzExtentionService.Query:output_type -> authz.extention.v1.QueryResponse - 39, // [39:44] is the sub-list for method output_type - 34, // [34:39] is the sub-list for method input_type - 34, // [34:34] is the sub-list for extension type_name - 34, // [34:34] is the sub-list for extension extendee - 0, // [0:34] is the sub-list for field type_name + 14, // 12: authz.extention.v1.MutateOperation.create_role:type_name -> authz.extention.v1.CreateRoleOperation + 15, // 13: authz.extention.v1.MutateOperation.delete_role:type_name -> authz.extention.v1.DeleteRoleOperation + 17, // 14: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 18, // 15: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 17, // 16: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 18, // 17: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 16, // 18: authz.extention.v1.CreateRoleOperation.permissions:type_name -> authz.extention.v1.RolePermission + 16, // 19: authz.extention.v1.DeleteRoleOperation.permissions:type_name -> authz.extention.v1.RolePermission + 22, // 20: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition + 19, // 21: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey + 41, // 22: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp + 42, // 23: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct + 24, // 24: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey + 43, // 25: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value + 20, // 26: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple + 19, // 27: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey + 21, // 28: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition + 26, // 29: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites + 27, // 30: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes + 31, // 31: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem + 39, // 32: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry + 40, // 33: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry + 36, // 34: authz.extention.v1.QueryRequest.operation:type_name -> authz.extention.v1.QueryOperation + 38, // 35: authz.extention.v1.QueryResponse.folder_parents:type_name -> authz.extention.v1.GetFolderParentsResult + 37, // 36: authz.extention.v1.QueryOperation.get_folder_parents:type_name -> authz.extention.v1.GetFolderParentsQuery + 33, // 37: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource + 30, // 38: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest + 23, // 39: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest + 28, // 40: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest + 0, // 41: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest + 34, // 42: authz.extention.v1.AuthzExtentionService.Query:input_type -> authz.extention.v1.QueryRequest + 32, // 43: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse + 25, // 44: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse + 29, // 45: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse + 1, // 46: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse + 35, // 47: authz.extention.v1.AuthzExtentionService.Query:output_type -> authz.extention.v1.QueryResponse + 43, // [43:48] is the sub-list for method output_type + 38, // [38:43] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_extention_proto_init() } @@ -2716,11 +2966,13 @@ func file_extention_proto_init() { (*MutateOperation_DeleteRoleBinding)(nil), (*MutateOperation_CreateTeamBinding)(nil), (*MutateOperation_DeleteTeamBinding)(nil), + (*MutateOperation_CreateRole)(nil), + (*MutateOperation_DeleteRole)(nil), } - file_extention_proto_msgTypes[32].OneofWrappers = []any{ + file_extention_proto_msgTypes[35].OneofWrappers = []any{ (*QueryResponse_FolderParents)(nil), } - file_extention_proto_msgTypes[33].OneofWrappers = []any{ + file_extention_proto_msgTypes[36].OneofWrappers = []any{ (*QueryOperation_GetFolderParents)(nil), } type x struct{} @@ -2729,7 +2981,7 @@ func file_extention_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_extention_proto_rawDesc), len(file_extention_proto_rawDesc)), NumEnums: 0, - NumMessages: 38, + NumMessages: 41, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/services/authz/proto/v1/extention.proto b/pkg/services/authz/proto/v1/extention.proto index 4e33f32db21..cb59e349f64 100644 --- a/pkg/services/authz/proto/v1/extention.proto +++ b/pkg/services/authz/proto/v1/extention.proto @@ -38,6 +38,8 @@ message MutateOperation { DeleteRoleBindingOperation delete_role_binding = 9; CreateTeamBindingOperation create_team_binding = 10; DeleteTeamBindingOperation delete_team_binding = 11; + CreateRoleOperation create_role = 12; + DeleteRoleOperation delete_role = 13; } } @@ -131,6 +133,29 @@ message DeleteTeamBindingOperation { string permission = 3; } +message CreateRoleOperation { + // kind of the role (Role/CoreRole/GlobalRole) + string role_kind = 1; + // uid of the role + string role_name = 2; + // permissions of the role + repeated RolePermission permissions = 3; +} + +message DeleteRoleOperation { + // kind of the role (Role/CoreRole/GlobalRole) + string role_kind = 1; + // uid of the role + string role_name = 2; + // permissions of the role + repeated RolePermission permissions = 3; +} + +message RolePermission { + string action = 1; + string scope = 2; +} + message Resource { // group of the resource (e.g: "dashboard.grafana.app") string group = 1; diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go index 79efce7373d..b1b6499dcd2 100644 --- a/pkg/services/authz/zanzana/common/tuple.go +++ b/pkg/services/authz/zanzana/common/tuple.go @@ -447,6 +447,14 @@ func ToOpenFGATuples(tuples []*authzextv1.Tuple) []*openfgav1.Tuple { return result } +func ToOpenFGADeleteTupleKey(tuples *openfgav1.TupleKey) *openfgav1.TupleKeyWithoutCondition { + return &openfgav1.TupleKeyWithoutCondition{ + User: tuples.GetUser(), + Relation: tuples.GetRelation(), + Object: tuples.GetObject(), + } +} + func AddRenderContext(req *openfgav1.CheckRequest) { if req.ContextualTuples == nil { req.ContextualTuples = &openfgav1.ContextualTupleKeys{} diff --git a/pkg/services/authz/zanzana/server/server_mutate.go b/pkg/services/authz/zanzana/server/server_mutate.go index 8d3696ceb82..cdd8c5b2f50 100644 --- a/pkg/services/authz/zanzana/server/server_mutate.go +++ b/pkg/services/authz/zanzana/server/server_mutate.go @@ -17,6 +17,7 @@ const ( OperationGroupUserOrgRole OperationGroup = "user_org_role" OperationGroupRoleBinding OperationGroup = "role_binding" OperationGroupTeamBinding OperationGroup = "team_binding" + OperationGroupRole OperationGroup = "role" ) func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) { @@ -73,6 +74,10 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au if err := s.mutateTeamBindings(ctx, storeInf, operations); err != nil { return nil, fmt.Errorf("failed to mutate team bindings: %w", err) } + case OperationGroupRole: + if err := s.mutateRoles(ctx, storeInf, operations); err != nil { + return nil, fmt.Errorf("failed to mutate roles: %w", err) + } default: s.logger.Warn("unsupported operation group", "operationGroup", operationGroup) } @@ -93,6 +98,8 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e return OperationGroupRoleBinding, nil case *authzextv1.MutateOperation_CreateTeamBinding, *authzextv1.MutateOperation_DeleteTeamBinding: return OperationGroupTeamBinding, nil + case *authzextv1.MutateOperation_CreateRole, *authzextv1.MutateOperation_DeleteRole: + return OperationGroupRole, nil } return OperationGroup(""), errors.New("unsupported mutate operation type") } diff --git a/pkg/services/authz/zanzana/server/server_mutate_roles.go b/pkg/services/authz/zanzana/server/server_mutate_roles.go new file mode 100644 index 00000000000..4c19b1fd288 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_roles.go @@ -0,0 +1,108 @@ +package server + +import ( + "context" + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func (s *Server) mutateRoles(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error { + ctx, span := s.tracer.Start(ctx, "server.mutateRoles") + defer span.End() + + writeTuples := make([]*openfgav1.TupleKey, 0) + deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0) + + for _, operation := range operations { + switch op := operation.Operation.(type) { + case *authzextv1.MutateOperation_CreateRole: + tuples, err := convertRoleToTuples(op.CreateRole.RoleName, op.CreateRole.Permissions) + if err != nil { + return err + } + writeTuples = append(writeTuples, tuples...) + case *authzextv1.MutateOperation_DeleteRole: + tuples, err := convertRoleToTuples(op.DeleteRole.RoleName, op.DeleteRole.Permissions) + if err != nil { + return err + } + deletes := make([]*openfgav1.TupleKeyWithoutCondition, 0, len(tuples)) + for _, tuple := range tuples { + deletes = append(deletes, common.ToOpenFGADeleteTupleKey(tuple)) + } + deleteTuples = append(deleteTuples, deletes...) + default: + s.logger.Debug("unsupported mutate operation", "operation", op) + } + } + + writeReq := &openfgav1.WriteRequest{ + StoreId: store.ID, + AuthorizationModelId: store.ModelID, + } + if len(writeTuples) > 0 { + writeReq.Writes = &openfgav1.WriteRequestWrites{ + TupleKeys: writeTuples, + OnDuplicate: "ignore", + } + } + if len(deleteTuples) > 0 { + writeReq.Deletes = &openfgav1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + OnMissing: "ignore", + } + } + + _, err := s.openfga.Write(ctx, writeReq) + if err != nil { + s.logger.Error("failed to write resource role binding tuples", "error", err) + return err + } + + return nil +} + +// convertRoleToTuples converts role and its permissions (action/scope) to v1 TupleKey format +// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys +func convertRoleToTuples(roleUID string, permissions []*authzextv1.RolePermission) ([]*openfgav1.TupleKey, error) { + // Convert to zanzana.RolePermission + rolePerms := make([]zanzana.RolePermission, 0, len(permissions)) + for _, perm := range permissions { + // Split the scope to get kind, attribute, identifier + kind, _, identifier := splitScope(perm.Scope) + rolePerms = append(rolePerms, zanzana.RolePermission{ + Action: perm.Action, + Kind: kind, + Identifier: identifier, + }) + } + + // Translate to Zanzana tuples + tuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms) + if err != nil { + return nil, err + } + + return tuples, nil +} + +func splitScope(scope string) (string, string, string) { + if scope == "" { + return "", "", "" + } + + fragments := strings.Split(scope, ":") + switch l := len(fragments); l { + case 1: // Splitting a wildcard scope "*" -> kind: "*"; attribute: "*"; identifier: "*" + return fragments[0], fragments[0], fragments[0] + case 2: // Splitting a wildcard scope with specified kind "dashboards:*" -> kind: "dashboards"; attribute: "*"; identifier: "*" + return fragments[0], fragments[1], fragments[1] + default: // Splitting a scope with all fields specified "dashboards:uid:my_dash" -> kind: "dashboards"; attribute: "uid"; identifier: "my_dash" + return fragments[0], fragments[1], strings.Join(fragments[2:], ":") + } +} diff --git a/pkg/services/authz/zanzana/server/server_mutate_roles_test.go b/pkg/services/authz/zanzana/server/server_mutate_roles_test.go new file mode 100644 index 00000000000..1128c0c0f72 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_roles_test.go @@ -0,0 +1,77 @@ +package server + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func setupMutateRoles(t *testing.T, srv *Server) *Server { + t.Helper() + + // seed tuples + tuples := []*openfgav1.TupleKey{ + common.NewTuple("role:foo_viewer#assignee", "view", "group_resource:dashboard.grafana.app/dashboards"), + } + + return setupOpenFGADatabase(t, srv, tuples) +} + +func testMutateRoles(t *testing.T, srv *Server) { + setupMutateRoles(t, srv) + + t.Run("should update role and delete old role permissions", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateRole{ + CreateRole: &v1.CreateRoleOperation{ + RoleName: "foo_viewer", + RoleKind: "Role", + Permissions: []*v1.RolePermission{ + { + Action: "dashboards:edit", + Scope: "dashboards:*", + }, + }, + }, + }, + }, + { + Operation: &v1.MutateOperation_DeleteRole{ + DeleteRole: &v1.DeleteRoleOperation{ + RoleName: "foo_viewer", + RoleKind: "Role", + Permissions: []*v1.RolePermission{ + { + Action: "dashboards:view", + Scope: "dashboards:*", + }, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + User: "role:foo_viewer#assignee", + Relation: "edit", + Object: "group_resource:", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 1) + require.Equal(t, "role:foo_viewer#assignee", res.Tuples[0].Key.User) + require.Equal(t, "group_resource:dashboard.grafana.app/dashboards", res.Tuples[0].Key.Object) + require.Equal(t, "edit", res.Tuples[0].Key.Relation) + }) +} diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index 514930bf2ba..14e8f629729 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -144,6 +144,10 @@ func TestIntegrationServer(t *testing.T) { t.Run("test mutate team bindings", func(t *testing.T) { testMutateTeamBindings(t, srv) }) + + t.Run("test mutate roles", func(t *testing.T) { + testMutateRoles(t, srv) + }) } func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { From 725df38dade6bca11c2f6248e57233cf98910db1 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 28 Nov 2025 11:45:14 +0100 Subject: [PATCH 29/31] Zanzana: Use team bindings write APIs on the client side (#114503) * Zanzana: Use team bindings write APIs on the client side * fix linter * remove unused code * Apply suggestions from code review Co-authored-by: Gabriel MABILLE * fix syntax --------- Co-authored-by: Gabriel MABILLE --- .../apis/iam/resource_permission_hooks.go | 3 - pkg/registry/apis/iam/team_binding_hooks.go | 206 ++----- .../apis/iam/team_binding_hooks_test.go | 516 ++++++++---------- 3 files changed, 280 insertions(+), 445 deletions(-) diff --git a/pkg/registry/apis/iam/resource_permission_hooks.go b/pkg/registry/apis/iam/resource_permission_hooks.go index e725726b2ec..010bb40049b 100644 --- a/pkg/registry/apis/iam/resource_permission_hooks.go +++ b/pkg/registry/apis/iam/resource_permission_hooks.go @@ -2,7 +2,6 @@ package iam import ( "context" - "errors" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,8 +13,6 @@ import ( ) var ( - errEmptyName = errors.New("name cannot be empty") - defaultWriteTimeout = 15 * time.Second ) diff --git a/pkg/registry/apis/iam/team_binding_hooks.go b/pkg/registry/apis/iam/team_binding_hooks.go index dcad58cae0a..298237df70a 100644 --- a/pkg/registry/apis/iam/team_binding_hooks.go +++ b/pkg/registry/apis/iam/team_binding_hooks.go @@ -10,42 +10,8 @@ import ( iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/grafana/grafana/pkg/services/authz/zanzana" ) -// convertTeamBindingToTuple converts a TeamBinding to a v1 TupleKey format -// TeamBinding represents a user's membership in a team with a specific permission level -func convertTeamBindingToTuple(tb *iamv0.TeamBinding) (*v1.TupleKey, error) { - if tb.Spec.Subject.Name == "" { - return nil, errEmptyName - } - - if tb.Spec.TeamRef.Name == "" { - return nil, errEmptyName - } - - // Map permission to relation - var relation string - switch tb.Spec.Permission { - case iamv0.TeamBindingTeamPermissionAdmin: - relation = zanzana.RelationTeamAdmin - case iamv0.TeamBindingTeamPermissionMember: - relation = zanzana.RelationTeamMember - default: - // Default to member if unknown permission - relation = zanzana.RelationTeamMember - } - - // Create tuple: user:{subjectUID} has {relation} relation to team:{teamUID} - tuple := &v1.TupleKey{ - User: zanzana.NewTupleEntry(zanzana.TypeUser, tb.Spec.Subject.Name, ""), - Relation: relation, - Object: zanzana.NewTupleEntry(zanzana.TypeTeam, tb.Spec.TeamRef.Name, ""), - } - - return tuple, nil -} - // AfterTeamBindingCreate is a post-create hook that writes the team binding to Zanzana (openFGA) func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) { if b.zClient == nil { @@ -79,20 +45,6 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime. hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() }() - tuple, err := convertTeamBindingToTuple(tb) - if err != nil { - b.logger.Error("failed to convert team binding to tuple", - "namespace", tb.Namespace, - "name", tb.Name, - "subject", tb.Spec.Subject.Name, - "teamRef", tb.Spec.TeamRef.Name, - "permission", tb.Spec.Permission, - "err", err, - ) - status = "failure" - return - } - b.logger.Debug("writing team binding to zanzana", "namespace", tb.Namespace, "name", tb.Name, @@ -104,12 +56,21 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime. ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err = b.zClient.Write(ctx, &v1.WriteRequest{ + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ Namespace: tb.Namespace, - Writes: &v1.WriteRequestWrites{ - TupleKeys: []*v1.TupleKey{tuple}, + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: tb.Spec.Subject.Name, + TeamName: tb.Spec.TeamRef.Name, + Permission: string(tb.Spec.Permission), + }, + }, + }, }, }) + if err != nil { status = "failure" b.logger.Error("failed to write team binding to zanzana", @@ -159,34 +120,28 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context. return nil, nil } - // Convert old team binding to tuple for deletion - var oldTuple *v1.TupleKey - var oldErr error - if oldTB.Spec.Subject.Name != "" && oldTB.Spec.TeamRef.Name != "" { - oldTuple, oldErr = convertTeamBindingToTuple(oldTB) - if oldErr != nil { - b.logger.Error("failed to convert old team binding to tuple", - "namespace", oldTB.Namespace, - "name", oldTB.Name, - "err", oldErr, - ) - return nil, nil - } - } - - // Convert new team binding to tuple for writing - var newTuple *v1.TupleKey - var newErr error - if newTB.Spec.Subject.Name != "" && newTB.Spec.TeamRef.Name != "" { - newTuple, newErr = convertTeamBindingToTuple(newTB) - if newErr != nil { - b.logger.Error("failed to convert new team binding to tuple", - "namespace", newTB.Namespace, - "name", newTB.Name, - "err", newErr, - ) - return nil, nil - } + operations := make([]*v1.MutateOperation, 0, 2) + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: oldTB.Spec.Subject.Name, + TeamName: oldTB.Spec.TeamRef.Name, + Permission: string(oldTB.Spec.Permission), + }, + }, + }) + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: newTB.Spec.Subject.Name, + TeamName: newTB.Spec.TeamRef.Name, + Permission: string(newTB.Spec.Permission), + }, + }, + }) + if len(operations) == 0 { + b.logger.Debug("no updates to team binding in zanzana", "namespace", newTB.Namespace, "name", newTB.Name) + return func(ctx context.Context, success bool) {}, nil } // Return a finish function that performs the zanzana write only on success @@ -224,57 +179,22 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context. ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - // Prepare write request - req := &v1.WriteRequest{ - Namespace: newTB.Namespace, - } - - // Add delete for old tuple - if oldTuple != nil && oldErr == nil { - deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{oldTuple}) - req.Deletes = &v1.WriteRequestDeletes{ - TupleKeys: deleteTuple, - } - b.logger.Debug("deleting existing team binding from zanzana", - "namespace", newTB.Namespace, - "subject", oldTB.Spec.Subject.Name, - "teamRef", oldTB.Spec.TeamRef.Name, - ) - } - - // Add write for new tuple - if newTuple != nil && newErr == nil { - req.Writes = &v1.WriteRequestWrites{ - TupleKeys: []*v1.TupleKey{newTuple}, - } - b.logger.Debug("writing new team binding to zanzana", - "namespace", newTB.Namespace, - "subject", newTB.Spec.Subject.Name, - "teamRef", newTB.Spec.TeamRef.Name, - ) - } - // Only make the request if there are deletes or writes - if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) { - err := b.zClient.Write(ctx, req) - if err != nil { - status = "failure" - b.logger.Error("failed to update team binding in zanzana", - "err", err, - "namespace", newTB.Namespace, - "name", newTB.Name, - ) - } else { - // Record successful tuple operations - if oldTuple != nil && oldErr == nil { - hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc() - } - if newTuple != nil && newErr == nil { - hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc() - } - } + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: newTB.Namespace, + Operations: operations, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to update team binding in zanzana", + "err", err, + "namespace", newTB.Namespace, + "name", newTB.Name, + ) } else { - b.logger.Debug("no tuples to update in zanzana", "namespace", newTB.Namespace, "name", newTB.Name) + // Record successful tuple operations + hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc() + hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc() } }() }, nil @@ -313,22 +233,6 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime. hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() }() - tuple, err := convertTeamBindingToTuple(tb) - if err != nil { - b.logger.Error("failed to convert team binding to tuple for deletion", - "namespace", tb.Namespace, - "name", tb.Name, - "subject", tb.Spec.Subject.Name, - "teamRef", tb.Spec.TeamRef.Name, - "err", err, - ) - status = "failure" - return - } - - // Convert tuple to TupleKeyWithoutCondition for deletion - deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{tuple}) - b.logger.Debug("deleting team binding from zanzana", "namespace", tb.Namespace, "name", tb.Name, @@ -340,10 +244,18 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime. ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err = b.zClient.Write(ctx, &v1.WriteRequest{ + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ Namespace: tb.Namespace, - Deletes: &v1.WriteRequestDeletes{ - TupleKeys: deleteTuple, + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: tb.Spec.Subject.Name, + TeamName: tb.Spec.TeamRef.Name, + Permission: string(tb.Spec.Permission), + }, + }, + }, }, }) if err != nil { diff --git a/pkg/registry/apis/iam/team_binding_hooks_test.go b/pkg/registry/apis/iam/team_binding_hooks_test.go index 2f56ba12c87..6b41e8359da 100644 --- a/pkg/registry/apis/iam/team_binding_hooks_test.go +++ b/pkg/registry/apis/iam/team_binding_hooks_test.go @@ -2,16 +2,18 @@ package iam import ( "context" + "slices" "sync" "testing" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/stretchr/testify/require" + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/infra/log" v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/stretchr/testify/require" ) func TestAfterTeamBindingCreate(t *testing.T) { @@ -40,30 +42,29 @@ func TestAfterTeamBindingCreate(t *testing.T) { }, } - testMemberBinding := func(ctx context.Context, req *v1.WriteRequest) error { + testMemberBinding := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) require.Equal(t, "org-1", req.Namespace) - require.Nil(t, req.Deletes) - expectedTuple := &v1.TupleKey{ - User: "user:user-1", - Relation: "member", - Object: "team:team-1", + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, } - actualTuple := req.Writes.TupleKeys[0] - require.Equal(t, expectedTuple.User, actualTuple.User) - require.Equal(t, expectedTuple.Relation, actualTuple.Relation) - require.Equal(t, expectedTuple.Object, actualTuple.Object) - require.Nil(t, actualTuple.Condition) + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMemberBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testMemberBinding} b.AfterTeamBindingCreate(&teamBinding, nil) wg.Wait() }) @@ -87,30 +88,29 @@ func TestAfterTeamBindingCreate(t *testing.T) { }, } - testAdminBinding := func(ctx context.Context, req *v1.WriteRequest) error { + testAdminBinding := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) require.Equal(t, "org-2", req.Namespace) - require.Nil(t, req.Deletes) - expectedTuple := &v1.TupleKey{ - User: "user:user-2", - Relation: "admin", - Object: "team:team-2", + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-2", + Permission: "admin", + }, + }, } - actualTuple := req.Writes.TupleKeys[0] - require.Equal(t, expectedTuple.User, actualTuple.User) - require.Equal(t, expectedTuple.Relation, actualTuple.Relation) - require.Equal(t, expectedTuple.Object, actualTuple.Object) - require.Nil(t, actualTuple.Condition) + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testAdminBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testAdminBinding} b.AfterTeamBindingCreate(&teamBinding, nil) wg.Wait() }) @@ -141,40 +141,6 @@ func TestAfterTeamBindingCreate(t *testing.T) { // Should not panic or error when zClient is nil builder.AfterTeamBindingCreate(&teamBinding, nil) }) - - t.Run("should handle conversion error gracefully", func(t *testing.T) { - // TeamBinding with empty subject name should fail conversion - teamBinding := iamv0.TeamBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: "binding-4", - Namespace: "org-4", - }, - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "", // Empty name should cause error - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-4", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, - } - - writeCalled := false - testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - // Should not be called due to conversion error - require.Fail(t, "Write should not be called when conversion fails") - return nil - } - - b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} - b.AfterTeamBindingCreate(&teamBinding, nil) - // Wait a bit to ensure the goroutine has time to process - // The goroutine will complete but won't call the write callback - time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when conversion fails") - }) } func TestBeginTeamBindingUpdate(t *testing.T) { @@ -218,33 +184,37 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testPermissionUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + testPermissionUpdate := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-1", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) - // Should delete old member permission - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, - ) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, + })) - // Should write new admin permission - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, - ) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "admin", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testPermissionUpdate} + b.zClient = &FakeZanzanaClient{mutateCallback: testPermissionUpdate} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -288,33 +258,36 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testUserUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + testUserUpdate := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) - // Should delete old user binding - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, - ) - - // Should write new user binding - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"}, - ) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, + })) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-1", + Permission: "member", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testUserUpdate} + b.zClient = &FakeZanzanaClient{mutateCallback: testUserUpdate} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -358,33 +331,35 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testTeamUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + testTeamUpdate := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-3", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) - // Should delete old team binding - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, - ) - - // Should write new team binding - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-2"}, - ) - + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "admin", + }, + }, + })) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-2", + Permission: "admin", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testTeamUpdate} + b.zClient = &FakeZanzanaClient{mutateCallback: testTeamUpdate} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -427,13 +402,13 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testNoWriteOnFailure := func(ctx context.Context, req *v1.WriteRequest) error { + testNoMutateOnFailure := func(ctx context.Context, req *v1.MutateRequest) error { // Should not be called when success=false - require.Fail(t, "Write should not be called when update fails") + require.Fail(t, "Mutate should not be called when update fails") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnFailure} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnFailure} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -441,7 +416,7 @@ func TestBeginTeamBindingUpdate(t *testing.T) { // Call finish function with success=false finishFunc(context.Background(), false) - // No wait needed since write should not be called + // No wait needed since mutate should not be called }) t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { @@ -497,7 +472,7 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, Spec: iamv0.TeamBindingSpec{ Subject: iamv0.TeamBindingspecSubject{ - Name: "", // Empty name - conversion will be skipped + Name: "", // Empty name will cause server-side error on delete }, TeamRef: iamv0.TeamBindingTeamRef{ Name: "team-1", @@ -522,27 +497,42 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testEmptyOldBinding := func(ctx context.Context, req *v1.WriteRequest) error { + testEmptyOldBinding := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-6", req.Namespace) + require.NotNil(t, req.Operations) - // Should not delete old binding (it was skipped due to empty name) - require.Nil(t, req.Deletes) + // Should have both delete and create operations + // The delete will have empty subject and fail server-side validation + require.Len(t, req.Operations, 2) - // Should write new binding - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"}, - ) + // First operation is delete with empty subject + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "", + TeamName: "team-1", + Permission: "member", + }, + }, + })) + + // Second operation is create with valid data + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-1", + Permission: "member", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testEmptyOldBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testEmptyOldBinding} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -585,22 +575,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - writeCalled := false - testNoWriteOnNoChange := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - require.Fail(t, "Write should not be called when bindings are identical") + mutateCalled := false + testNoMutateOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error { + mutateCalled = true + require.Fail(t, "Mutate should not be called when bindings are identical") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnNoChange} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnNoChange} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) require.Nil(t, finishFunc) // Should return nil when bindings are identical - // Verify write was never called + // Verify mutate was never called time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when bindings are identical") + require.False(t, mutateCalled, "Mutate callback should not be called when bindings are identical") }) t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) { @@ -636,22 +626,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - writeCalled := false - testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - require.Fail(t, "Write should not be called when new binding has empty subject name") + mutateCalled := false + testNoMutateOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error { + mutateCalled = true + require.Fail(t, "Mutate should not be called when new binding has empty subject name") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnInvalidBinding} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name - // Verify write was never called + // Verify mutate was never called time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name") + require.False(t, mutateCalled, "Mutate callback should not be called when new binding has empty subject name") }) t.Run("should return nil finish func when new binding has empty team ref name", func(t *testing.T) { @@ -687,22 +677,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - writeCalled := false - testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - require.Fail(t, "Write should not be called when new binding has empty team ref name") + mutateCalled := false + testNoMutateOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error { + mutateCalled = true + require.Fail(t, "Mutate should not be called when new binding has empty team ref name") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnInvalidBinding} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) require.Nil(t, finishFunc) // Should return nil when new binding has empty team ref name - // Verify write was never called + // Verify mutate was never called time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when new binding has empty team ref name") + require.False(t, mutateCalled, "Mutate callback should not be called when new binding has empty team ref name") }) } @@ -732,26 +722,28 @@ func TestAfterTeamBindingDelete(t *testing.T) { }, } - testMemberDelete := func(ctx context.Context, req *v1.WriteRequest) error { + testMemberDelete := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-1", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) - // Should have deletes but no writes - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Nil(t, req.Writes) - - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, - ) + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, + } + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMemberDelete} + b.zClient = &FakeZanzanaClient{mutateCallback: testMemberDelete} b.AfterTeamBindingDelete(&teamBinding, nil) wg.Wait() }) @@ -775,26 +767,28 @@ func TestAfterTeamBindingDelete(t *testing.T) { }, } - testAdminDelete := func(ctx context.Context, req *v1.WriteRequest) error { + testAdminDelete := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) - // Should have deletes but no writes - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Nil(t, req.Writes) - - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-2", Relation: "admin", Object: "team:team-2"}, - ) + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-2", + Permission: "admin", + }, + }, + } + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testAdminDelete} + b.zClient = &FakeZanzanaClient{mutateCallback: testAdminDelete} b.AfterTeamBindingDelete(&teamBinding, nil) wg.Wait() }) @@ -826,8 +820,9 @@ func TestAfterTeamBindingDelete(t *testing.T) { builder.AfterTeamBindingDelete(&teamBinding, nil) }) - t.Run("should handle conversion error gracefully", func(t *testing.T) { - // TeamBinding with empty team ref name should fail conversion + t.Run("should handle empty team name gracefully", func(t *testing.T) { + wg.Add(1) + // TeamBinding with empty team ref name will be sent to server which will return error teamBinding := iamv0.TeamBinding{ ObjectMeta: metav1.ObjectMeta{ Name: "binding-4", @@ -838,129 +833,60 @@ func TestAfterTeamBindingDelete(t *testing.T) { Name: "user-4", }, TeamRef: iamv0.TeamBindingTeamRef{ - Name: "", // Empty name should cause error + Name: "", // Empty name will cause server-side error }, Permission: iamv0.TeamBindingTeamPermissionMember, }, } - writeCalled := false - testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - // Should not be called due to conversion error - require.Fail(t, "Write should not be called when conversion fails") + testErrorHandling := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) + require.Equal(t, "org-4", req.Namespace) + + // Operation will have empty team name, which would fail server-side validation + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-4", + TeamName: "", + Permission: "member", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} + b.zClient = &FakeZanzanaClient{mutateCallback: testErrorHandling} b.AfterTeamBindingDelete(&teamBinding, nil) - // Wait a bit to ensure the goroutine has time to process - // The goroutine will complete but won't call the write callback - time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when conversion fails") + wg.Wait() }) } -func TestConvertTeamBindingToTuple(t *testing.T) { - t.Run("should convert member permission correctly", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-1", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-1", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, +func containsTeamBindingOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool { + return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool { + switch operation.Operation.(type) { + case *v1.MutateOperation_DeleteTeamBinding: + deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteTeamBinding) + deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteTeamBinding) + if !ok { + return false + } + return deleteO.DeleteTeamBinding.SubjectName == deleteOperation.DeleteTeamBinding.SubjectName && + deleteO.DeleteTeamBinding.TeamName == deleteOperation.DeleteTeamBinding.TeamName && + deleteO.DeleteTeamBinding.Permission == deleteOperation.DeleteTeamBinding.Permission + case *v1.MutateOperation_CreateTeamBinding: + createOperation := operation.Operation.(*v1.MutateOperation_CreateTeamBinding) + createO, ok := o.Operation.(*v1.MutateOperation_CreateTeamBinding) + if !ok { + return false + } + return createO.CreateTeamBinding.SubjectName == createOperation.CreateTeamBinding.SubjectName && + createO.CreateTeamBinding.TeamName == createOperation.CreateTeamBinding.TeamName && + createO.CreateTeamBinding.Permission == createOperation.CreateTeamBinding.Permission } - - tuple, err := convertTeamBindingToTuple(tb) - require.NoError(t, err) - require.NotNil(t, tuple) - require.Equal(t, "user:user-1", tuple.User) - require.Equal(t, "member", tuple.Relation) - require.Equal(t, "team:team-1", tuple.Object) - require.Nil(t, tuple.Condition) - }) - - t.Run("should convert admin permission correctly", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-2", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-2", - }, - Permission: iamv0.TeamBindingTeamPermissionAdmin, - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.NoError(t, err) - require.NotNil(t, tuple) - require.Equal(t, "user:user-2", tuple.User) - require.Equal(t, "admin", tuple.Relation) - require.Equal(t, "team:team-2", tuple.Object) - require.Nil(t, tuple.Condition) - }) - - t.Run("should return error for empty subject name", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-1", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.Error(t, err) - require.Nil(t, tuple) - require.Equal(t, errEmptyName, err) - }) - - t.Run("should return error for empty team ref name", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-1", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.Error(t, err) - require.Nil(t, tuple) - require.Equal(t, errEmptyName, err) - }) - - t.Run("should default to member for unknown permission", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-1", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-1", - }, - Permission: "unknown", // Invalid permission - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.NoError(t, err) - require.NotNil(t, tuple) - // Should default to member relation - require.Equal(t, "member", tuple.Relation) + return false }) } From eafc8ab1cd60dcb588dea1e264cfda06a1d2f77e Mon Sep 17 00:00:00 2001 From: Steve Simpson Date: Fri, 28 Nov 2025 11:51:56 +0100 Subject: [PATCH 30/31] Alerting: Foundations of historian app. (#114463) We have two historians in alerting - alert state and notification. The intention of this app is to provide query capabilities for both. In this initial commit, the existing /history API is simply cloned to the new app. It is identical except that it will send Kubernetes-style error responses instead of Grafana-style. This approach was taken to implement the new app more iteratively - ideally we would define a new API, but this requires quite a significant overhaul of the backend code. --- apps/alerting/historian/Makefile | 9 + apps/alerting/historian/go.mod | 86 +++++ apps/alerting/historian/go.sum | 238 +++++++++++++ .../historian/kinds/cue.mod/module.cue | 2 + apps/alerting/historian/kinds/manifest.cue | 40 +++ .../alertinghistorian/v0alpha1/constants.go | 18 + .../v0alpha1/dummy_client_gen.go | 99 ++++++ .../v0alpha1/dummy_codec_gen.go | 28 ++ .../v0alpha1/dummy_metadata_gen.go | 31 ++ .../v0alpha1/dummy_object_gen.go | 319 ++++++++++++++++++ .../v0alpha1/dummy_schema_gen.go | 34 ++ .../v0alpha1/dummy_spec_gen.go | 14 + .../v0alpha1/dummy_status_gen.go | 44 +++ ...getalertstatehistory_response_types_gen.go | 15 + .../getalertstatequery_response_types_gen.go | 3 + .../pkg/apis/alertinghistorian_manifest.go | 175 ++++++++++ apps/alerting/historian/pkg/app/app.go | 45 +++ .../historian/pkg/app/config/config.go | 9 + .../dummy/v0alpha1/dummy_object_gen.ts | 49 +++ .../dummy/v0alpha1/types.metadata.gen.ts | 30 ++ .../dummy/v0alpha1/types.spec.gen.ts | 11 + .../dummy/v0alpha1/types.status.gen.ts | 30 ++ go.mod | 2 + .../apps/alerting/historian/handlers.go | 60 ++++ .../apps/alerting/historian/handlers_test.go | 309 +++++++++++++++++ .../apps/alerting/historian/register.go | 58 ++++ pkg/registry/apps/apps.go | 6 + pkg/registry/apps/apps_test.go | 5 +- pkg/registry/apps/wireset.go | 2 + pkg/server/wire_gen.go | 13 +- pkg/services/featuremgmt/toggles_gen.json | 2 +- 31 files changed, 1782 insertions(+), 4 deletions(-) create mode 100644 apps/alerting/historian/Makefile create mode 100644 apps/alerting/historian/go.mod create mode 100644 apps/alerting/historian/go.sum create mode 100644 apps/alerting/historian/kinds/cue.mod/module.cue create mode 100644 apps/alerting/historian/kinds/manifest.cue create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/constants.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_client_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_codec_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_metadata_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_object_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_schema_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_spec_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_status_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatehistory_response_types_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatequery_response_types_gen.go create mode 100644 apps/alerting/historian/pkg/apis/alertinghistorian_manifest.go create mode 100644 apps/alerting/historian/pkg/app/app.go create mode 100644 apps/alerting/historian/pkg/app/config/config.go create mode 100644 apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/dummy_object_gen.ts create mode 100644 apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.metadata.gen.ts create mode 100644 apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.spec.gen.ts create mode 100644 apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.status.gen.ts create mode 100644 pkg/registry/apps/alerting/historian/handlers.go create mode 100644 pkg/registry/apps/alerting/historian/handlers_test.go create mode 100644 pkg/registry/apps/alerting/historian/register.go diff --git a/apps/alerting/historian/Makefile b/apps/alerting/historian/Makefile new file mode 100644 index 00000000000..378b08281e6 --- /dev/null +++ b/apps/alerting/historian/Makefile @@ -0,0 +1,9 @@ +include ../../sdk.mk + +.PHONY: generate # Run Grafana App SDK code generation +generate: install-app-sdk update-app-sdk + @$(APP_SDK_BIN) generate \ + --source=./kinds/ \ + --gogenpath=./pkg/apis \ + --grouping=group \ + --defencoding=none diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod new file mode 100644 index 00000000000..eec687eb79f --- /dev/null +++ b/apps/alerting/historian/go.mod @@ -0,0 +1,86 @@ +module github.com/grafana/grafana/apps/alerting/historian + +go 1.25.3 + +require ( + github.com/grafana/grafana-app-sdk v0.48.2 + k8s.io/apimachinery v0.34.2 + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.2 // indirect + k8s.io/apiextensions-apiserver v0.34.2 // indirect + k8s.io/client-go v0.34.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum new file mode 100644 index 00000000000..a0f2396b250 --- /dev/null +++ b/apps/alerting/historian/go.sum @@ -0,0 +1,238 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= +github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= +github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= +github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= +github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/alerting/historian/kinds/cue.mod/module.cue b/apps/alerting/historian/kinds/cue.mod/module.cue new file mode 100644 index 00000000000..2f45dd38aec --- /dev/null +++ b/apps/alerting/historian/kinds/cue.mod/module.cue @@ -0,0 +1,2 @@ +module: "github.com/grafana/grafana/apps/alerting/historian/kinds" +language: version: "v0.8.2" diff --git a/apps/alerting/historian/kinds/manifest.cue b/apps/alerting/historian/kinds/manifest.cue new file mode 100644 index 00000000000..6f2bfc21cb9 --- /dev/null +++ b/apps/alerting/historian/kinds/manifest.cue @@ -0,0 +1,40 @@ +package kinds + +manifest: { + appName: "alerting-historian" + groupOverride: "historian.alerting.grafana.app" + versions: { + "v0alpha1": v0alpha1 + } +} + +v0alpha1: { + kinds: [dummyv0alpha1] + + routes: { + namespaced: { + // This endpoint is an exact copy of the existing /history endpoint, + // with the exception that error responses will be Kubernetes-style, + // not Grafana-style. It will be replaced in the future with a better + // more schema-friendly API. + "/alertstate/history": { + "GET": { + response: { + body: [string]: _ + } + responseMetadata: typeMeta: false + } + } + } + } +} + +dummyv0alpha1: { + kind: "Dummy" + schema: { + // Spec is the schema of our resource. The spec should include all the user-editable information for the kind. + spec: { + dummyField: int + } + } +} \ No newline at end of file diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/constants.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/constants.go new file mode 100644 index 00000000000..85867e8de0e --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/constants.go @@ -0,0 +1,18 @@ +package v0alpha1 + +import "k8s.io/apimachinery/pkg/runtime/schema" + +const ( + // APIGroup is the API group used by all kinds in this package + APIGroup = "historian.alerting.grafana.app" + // APIVersion is the API version used by all kinds in this package + APIVersion = "v0alpha1" +) + +var ( + // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package + GroupVersion = schema.GroupVersion{ + Group: APIGroup, + Version: APIVersion, + } +) diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_client_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_client_gen.go new file mode 100644 index 00000000000..fba40cf2385 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_client_gen.go @@ -0,0 +1,99 @@ +package v0alpha1 + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type DummyClient struct { + client *resource.TypedClient[*Dummy, *DummyList] +} + +func NewDummyClient(client resource.Client) *DummyClient { + return &DummyClient{ + client: resource.NewTypedClient[*Dummy, *DummyList](client, DummyKind()), + } +} + +func NewDummyClientFromGenerator(generator resource.ClientGenerator) (*DummyClient, error) { + c, err := generator.ClientFor(DummyKind()) + if err != nil { + return nil, err + } + return NewDummyClient(c), nil +} + +func (c *DummyClient) Get(ctx context.Context, identifier resource.Identifier) (*Dummy, error) { + return c.client.Get(ctx, identifier) +} + +func (c *DummyClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*DummyList, error) { + return c.client.List(ctx, namespace, opts) +} + +func (c *DummyClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*DummyList, error) { + resp, err := c.client.List(ctx, namespace, resource.ListOptions{ + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + for resp.GetContinue() != "" { + page, err := c.client.List(ctx, namespace, resource.ListOptions{ + Continue: resp.GetContinue(), + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + resp.SetContinue(page.GetContinue()) + resp.SetResourceVersion(page.GetResourceVersion()) + resp.SetItems(append(resp.GetItems(), page.GetItems()...)) + } + return resp, nil +} + +func (c *DummyClient) Create(ctx context.Context, obj *Dummy, opts resource.CreateOptions) (*Dummy, error) { + // Make sure apiVersion and kind are set + obj.APIVersion = GroupVersion.Identifier() + obj.Kind = DummyKind().Kind() + return c.client.Create(ctx, obj, opts) +} + +func (c *DummyClient) Update(ctx context.Context, obj *Dummy, opts resource.UpdateOptions) (*Dummy, error) { + return c.client.Update(ctx, obj, opts) +} + +func (c *DummyClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Dummy, error) { + return c.client.Patch(ctx, identifier, req, opts) +} + +func (c *DummyClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus DummyStatus, opts resource.UpdateOptions) (*Dummy, error) { + return c.client.Update(ctx, &Dummy{ + TypeMeta: metav1.TypeMeta{ + Kind: DummyKind().Kind(), + APIVersion: GroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: opts.ResourceVersion, + Namespace: identifier.Namespace, + Name: identifier.Name, + }, + Status: newStatus, + }, resource.UpdateOptions{ + Subresource: "status", + ResourceVersion: opts.ResourceVersion, + }) +} + +func (c *DummyClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + return c.client.Delete(ctx, identifier, opts) +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_codec_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_codec_gen.go new file mode 100644 index 00000000000..6512ec5d36d --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_codec_gen.go @@ -0,0 +1,28 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "encoding/json" + "io" + + "github.com/grafana/grafana-app-sdk/resource" +) + +// DummyJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding +type DummyJSONCodec struct{} + +// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` +func (*DummyJSONCodec) Read(reader io.Reader, into resource.Object) error { + return json.NewDecoder(reader).Decode(into) +} + +// Write writes JSON-encoded bytes into `writer` marshaled from `from` +func (*DummyJSONCodec) Write(writer io.Writer, from resource.Object) error { + return json.NewEncoder(writer).Encode(from) +} + +// Interface compliance checks +var _ resource.Codec = &DummyJSONCodec{} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_metadata_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_metadata_gen.go new file mode 100644 index 00000000000..f56576b2b72 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_metadata_gen.go @@ -0,0 +1,31 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + time "time" +) + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +type DummyMetadata struct { + UpdateTimestamp time.Time `json:"updateTimestamp"` + CreatedBy string `json:"createdBy"` + Uid string `json:"uid"` + CreationTimestamp time.Time `json:"creationTimestamp"` + DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` + Finalizers []string `json:"finalizers"` + ResourceVersion string `json:"resourceVersion"` + Generation int64 `json:"generation"` + UpdatedBy string `json:"updatedBy"` + Labels map[string]string `json:"labels"` +} + +// NewDummyMetadata creates a new DummyMetadata object. +func NewDummyMetadata() *DummyMetadata { + return &DummyMetadata{ + Finalizers: []string{}, + Labels: map[string]string{}, + } +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_object_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_object_gen.go new file mode 100644 index 00000000000..827abbaa7a2 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_object_gen.go @@ -0,0 +1,319 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "fmt" + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "time" +) + +// +k8s:openapi-gen=true +type Dummy struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ObjectMeta `json:"metadata" yaml:"metadata"` + + // Spec is the spec of the Dummy + Spec DummySpec `json:"spec" yaml:"spec"` + + Status DummyStatus `json:"status" yaml:"status"` +} + +func (o *Dummy) GetSpec() any { + return o.Spec +} + +func (o *Dummy) SetSpec(spec any) error { + cast, ok := spec.(DummySpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *Dummy) GetSubresources() map[string]any { + return map[string]any{ + "status": o.Status, + } +} + +func (o *Dummy) GetSubresource(name string) (any, bool) { + switch name { + case "status": + return o.Status, true + default: + return nil, false + } +} + +func (o *Dummy) SetSubresource(name string, value any) error { + switch name { + case "status": + cast, ok := value.(DummyStatus) + if !ok { + return fmt.Errorf("cannot set status type %#v, not of type DummyStatus", value) + } + o.Status = cast + return nil + default: + return fmt.Errorf("subresource '%s' does not exist", name) + } +} + +func (o *Dummy) GetStaticMetadata() resource.StaticMetadata { + gvk := o.GroupVersionKind() + return resource.StaticMetadata{ + Name: o.ObjectMeta.Name, + Namespace: o.ObjectMeta.Namespace, + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } +} + +func (o *Dummy) SetStaticMetadata(metadata resource.StaticMetadata) { + o.Name = metadata.Name + o.Namespace = metadata.Namespace + o.SetGroupVersionKind(schema.GroupVersionKind{ + Group: metadata.Group, + Version: metadata.Version, + Kind: metadata.Kind, + }) +} + +func (o *Dummy) GetCommonMetadata() resource.CommonMetadata { + dt := o.DeletionTimestamp + var deletionTimestamp *time.Time + if dt != nil { + deletionTimestamp = &dt.Time + } + // Legacy ExtraFields support + extraFields := make(map[string]any) + if o.Annotations != nil { + extraFields["annotations"] = o.Annotations + } + if o.ManagedFields != nil { + extraFields["managedFields"] = o.ManagedFields + } + if o.OwnerReferences != nil { + extraFields["ownerReferences"] = o.OwnerReferences + } + return resource.CommonMetadata{ + UID: string(o.UID), + ResourceVersion: o.ResourceVersion, + Generation: o.Generation, + Labels: o.Labels, + CreationTimestamp: o.CreationTimestamp.Time, + DeletionTimestamp: deletionTimestamp, + Finalizers: o.Finalizers, + UpdateTimestamp: o.GetUpdateTimestamp(), + CreatedBy: o.GetCreatedBy(), + UpdatedBy: o.GetUpdatedBy(), + ExtraFields: extraFields, + } +} + +func (o *Dummy) SetCommonMetadata(metadata resource.CommonMetadata) { + o.UID = types.UID(metadata.UID) + o.ResourceVersion = metadata.ResourceVersion + o.Generation = metadata.Generation + o.Labels = metadata.Labels + o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) + if metadata.DeletionTimestamp != nil { + dt := metav1.NewTime(*metadata.DeletionTimestamp) + o.DeletionTimestamp = &dt + } else { + o.DeletionTimestamp = nil + } + o.Finalizers = metadata.Finalizers + if o.Annotations == nil { + o.Annotations = make(map[string]string) + } + if !metadata.UpdateTimestamp.IsZero() { + o.SetUpdateTimestamp(metadata.UpdateTimestamp) + } + if metadata.CreatedBy != "" { + o.SetCreatedBy(metadata.CreatedBy) + } + if metadata.UpdatedBy != "" { + o.SetUpdatedBy(metadata.UpdatedBy) + } + // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields + if metadata.ExtraFields != nil { + if annotations, ok := metadata.ExtraFields["annotations"]; ok { + if cast, ok := annotations.(map[string]string); ok { + o.Annotations = cast + } + } + if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { + if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { + o.ManagedFields = cast + } + } + if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { + if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { + o.OwnerReferences = cast + } + } + } +} + +func (o *Dummy) GetCreatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/createdBy"] +} + +func (o *Dummy) SetCreatedBy(createdBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy +} + +func (o *Dummy) GetUpdateTimestamp() time.Time { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) + return parsed +} + +func (o *Dummy) SetUpdateTimestamp(updateTimestamp time.Time) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) +} + +func (o *Dummy) GetUpdatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/updatedBy"] +} + +func (o *Dummy) SetUpdatedBy(updatedBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy +} + +func (o *Dummy) Copy() resource.Object { + return resource.CopyObject(o) +} + +func (o *Dummy) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *Dummy) DeepCopy() *Dummy { + cpy := &Dummy{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *Dummy) DeepCopyInto(dst *Dummy) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) + o.Spec.DeepCopyInto(&dst.Spec) + o.Status.DeepCopyInto(&dst.Status) +} + +// Interface compliance compile-time check +var _ resource.Object = &Dummy{} + +// +k8s:openapi-gen=true +type DummyList struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ListMeta `json:"metadata" yaml:"metadata"` + Items []Dummy `json:"items" yaml:"items"` +} + +func (o *DummyList) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *DummyList) Copy() resource.ListObject { + cpy := &DummyList{ + TypeMeta: o.TypeMeta, + Items: make([]Dummy, len(o.Items)), + } + o.ListMeta.DeepCopyInto(&cpy.ListMeta) + for i := 0; i < len(o.Items); i++ { + if item, ok := o.Items[i].Copy().(*Dummy); ok { + cpy.Items[i] = *item + } + } + return cpy +} + +func (o *DummyList) GetItems() []resource.Object { + items := make([]resource.Object, len(o.Items)) + for i := 0; i < len(o.Items); i++ { + items[i] = &o.Items[i] + } + return items +} + +func (o *DummyList) SetItems(items []resource.Object) { + o.Items = make([]Dummy, len(items)) + for i := 0; i < len(items); i++ { + o.Items[i] = *items[i].(*Dummy) + } +} + +func (o *DummyList) DeepCopy() *DummyList { + cpy := &DummyList{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *DummyList) DeepCopyInto(dst *DummyList) { + resource.CopyObjectInto(dst, o) +} + +// Interface compliance compile-time check +var _ resource.ListObject = &DummyList{} + +// Copy methods for all subresource types + +// DeepCopy creates a full deep copy of Spec +func (s *DummySpec) DeepCopy() *DummySpec { + cpy := &DummySpec{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies Spec into another Spec object +func (s *DummySpec) DeepCopyInto(dst *DummySpec) { + resource.CopyObjectInto(dst, s) +} + +// DeepCopy creates a full deep copy of DummyStatus +func (s *DummyStatus) DeepCopy() *DummyStatus { + cpy := &DummyStatus{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies DummyStatus into another DummyStatus object +func (s *DummyStatus) DeepCopyInto(dst *DummyStatus) { + resource.CopyObjectInto(dst, s) +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_schema_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_schema_gen.go new file mode 100644 index 00000000000..a7d3de4ed1c --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_schema_gen.go @@ -0,0 +1,34 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" +) + +// schema is unexported to prevent accidental overwrites +var ( + schemaDummy = resource.NewSimpleSchema("historian.alerting.grafana.app", "v0alpha1", &Dummy{}, &DummyList{}, resource.WithKind("Dummy"), + resource.WithPlural("dummys"), resource.WithScope(resource.NamespacedScope)) + kindDummy = resource.Kind{ + Schema: schemaDummy, + Codecs: map[resource.KindEncoding]resource.Codec{ + resource.KindEncodingJSON: &DummyJSONCodec{}, + }, + } +) + +// Kind returns a resource.Kind for this Schema with a JSON codec +func DummyKind() resource.Kind { + return kindDummy +} + +// Schema returns a resource.SimpleSchema representation of Dummy +func DummySchema() *resource.SimpleSchema { + return schemaDummy +} + +// Interface compliance checks +var _ resource.Schema = kindDummy diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_spec_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_spec_gen.go new file mode 100644 index 00000000000..16d4eab9409 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_spec_gen.go @@ -0,0 +1,14 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// Spec is the schema of our resource. The spec should include all the user-editable information for the kind. +// +k8s:openapi-gen=true +type DummySpec struct { + DummyField int64 `json:"dummyField"` +} + +// NewDummySpec creates a new DummySpec object. +func NewDummySpec() *DummySpec { + return &DummySpec{} +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_status_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_status_gen.go new file mode 100644 index 00000000000..36d064053f5 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_status_gen.go @@ -0,0 +1,44 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type DummystatusOperatorState struct { + // lastEvaluation is the ResourceVersion last evaluated + LastEvaluation string `json:"lastEvaluation"` + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + State DummyStatusOperatorStateState `json:"state"` + // descriptiveState is an optional more descriptive state field which has no requirements on format + DescriptiveState *string `json:"descriptiveState,omitempty"` + // details contains any extra information that is operator-specific + Details map[string]interface{} `json:"details,omitempty"` +} + +// NewDummystatusOperatorState creates a new DummystatusOperatorState object. +func NewDummystatusOperatorState() *DummystatusOperatorState { + return &DummystatusOperatorState{} +} + +// +k8s:openapi-gen=true +type DummyStatus struct { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + OperatorStates map[string]DummystatusOperatorState `json:"operatorStates,omitempty"` + // additionalFields is reserved for future use + AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` +} + +// NewDummyStatus creates a new DummyStatus object. +func NewDummyStatus() *DummyStatus { + return &DummyStatus{} +} + +// +k8s:openapi-gen=true +type DummyStatusOperatorStateState string + +const ( + DummyStatusOperatorStateStateSuccess DummyStatusOperatorStateState = "success" + DummyStatusOperatorStateStateInProgress DummyStatusOperatorStateState = "in_progress" + DummyStatusOperatorStateStateFailed DummyStatusOperatorStateState = "failed" +) diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatehistory_response_types_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatehistory_response_types_gen.go new file mode 100644 index 00000000000..b9f54ed4718 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatehistory_response_types_gen.go @@ -0,0 +1,15 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type GetAlertstatehistory struct { + Body map[string]interface{} `json:"body"` +} + +// NewGetAlertstatehistory creates a new GetAlertstatehistory object. +func NewGetAlertstatehistory() *GetAlertstatehistory { + return &GetAlertstatehistory{ + Body: map[string]interface{}{}, + } +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatequery_response_types_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatequery_response_types_gen.go new file mode 100644 index 00000000000..90130b85cf3 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatequery_response_types_gen.go @@ -0,0 +1,3 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian_manifest.go b/apps/alerting/historian/pkg/apis/alertinghistorian_manifest.go new file mode 100644 index 00000000000..4e6401e5b1d --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian_manifest.go @@ -0,0 +1,175 @@ +// +// This file is generated by grafana-app-sdk +// DO NOT EDIT +// + +package apis + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + v0alpha1 "github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1" +) + +var ( + rawSchemaDummyv0alpha1 = []byte(`{"Dummy":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"description":"Spec is the schema of our resource. The spec should include all the user-editable information for the kind.","properties":{"dummyField":{"type":"integer"}},"required":["dummyField"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaDummyv0alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaDummyv0alpha1, &versionSchemaDummyv0alpha1) +) + +var appManifestData = app.ManifestData{ + AppName: "alerting-historian", + Group: "historian.alerting.grafana.app", + PreferredVersion: "v0alpha1", + Versions: []app.ManifestVersion{ + { + Name: "v0alpha1", + Served: true, + Kinds: []app.ManifestVersionKind{ + { + Kind: "Dummy", + Plural: "Dummys", + Scope: "Namespaced", + Conversion: false, + Schema: &versionSchemaDummyv0alpha1, + }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{ + "/alertstate/history": { + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + + OperationId: "getAlertstatehistory", + + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + Default: &spec3.Response{ + ResponseProps: spec3.ResponseProps{ + Description: "Default OK response", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "body": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{}, + }, + }, + }, + }, + }, + }, + }, + }, + Required: []string{ + "body", + }, + }}, + }}, + }, + }, + }, + }}, + }, + }, + }, + }, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + }, +} + +func LocalManifest() app.Manifest { + return app.NewEmbeddedManifest(appManifestData) +} + +func RemoteManifest() app.Manifest { + return app.NewAPIServerManifest("alerting-historian") +} + +var kindVersionToGoType = map[string]resource.Kind{ + "Dummy/v0alpha1": v0alpha1.DummyKind(), +} + +// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. +// If there is no association for the provided Kind and Version, exists will return false. +func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { + goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] + return goType, exists +} + +var customRouteToGoResponseType = map[string]any{ + "v0alpha1||/alertstate/history|GET": v0alpha1.GetAlertstatehistory{}, +} + +// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. +// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. +// If there is no association for the provided kind, version, custom route path, and method, exists will return false. +// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) +func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoParamsType = map[string]runtime.Object{} + +func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoRequestBodyType = map[string]any{} + +func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +type GoTypeAssociator struct{} + +func NewGoTypeAssociator() *GoTypeAssociator { + return &GoTypeAssociator{} +} + +func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { + return ManifestGoTypeAssociator(kind, version) +} +func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { + return ManifestCustomRouteQueryAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) +} diff --git a/apps/alerting/historian/pkg/app/app.go b/apps/alerting/historian/pkg/app/app.go new file mode 100644 index 00000000000..8d5feb39ec7 --- /dev/null +++ b/apps/alerting/historian/pkg/app/app.go @@ -0,0 +1,45 @@ +package app + +import ( + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/simple" + + "github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1" + "github.com/grafana/grafana/apps/alerting/historian/pkg/app/config" +) + +func New(cfg app.Config) (app.App, error) { + runtimeConfig := cfg.SpecificConfig.(config.RuntimeConfig) + + simpleConfig := simple.AppConfig{ + Name: "alerting.historian", + KubeConfig: cfg.KubeConfig, + VersionedCustomRoutes: map[string]simple.AppVersionRouteHandlers{ + "v0alpha1": { + { + Namespaced: true, + Path: "/alertstate/history", + Method: "GET", + }: runtimeConfig.GetAlertStateHistoryHandler, + }, + }, + // TODO: Remove when SDK is fixed. + ManagedKinds: []simple.AppManagedKind{ + { + Kind: v0alpha1.DummyKind(), + }, + }, + } + + a, err := simple.NewApp(simpleConfig) + if err != nil { + return nil, err + } + + err = a.ValidateManifest(cfg.ManifestData) + if err != nil { + return nil, err + } + + return a, nil +} diff --git a/apps/alerting/historian/pkg/app/config/config.go b/apps/alerting/historian/pkg/app/config/config.go new file mode 100644 index 00000000000..5a40503dea8 --- /dev/null +++ b/apps/alerting/historian/pkg/app/config/config.go @@ -0,0 +1,9 @@ +package config + +import ( + "github.com/grafana/grafana-app-sdk/simple" +) + +type RuntimeConfig struct { + GetAlertStateHistoryHandler simple.AppCustomRouteHandler +} diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/dummy_object_gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/dummy_object_gen.ts new file mode 100644 index 00000000000..8189d09b6fb --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/dummy_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface Dummy { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.metadata.gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.metadata.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +export interface Metadata { + updateTimestamp: string; + createdBy: string; + uid: string; + creationTimestamp: string; + deletionTimestamp?: string; + finalizers: string[]; + resourceVersion: string; + generation: number; + updatedBy: string; + labels: Record; +} + +export const defaultMetadata = (): Metadata => ({ + updateTimestamp: "", + createdBy: "", + uid: "", + creationTimestamp: "", + finalizers: [], + resourceVersion: "", + generation: 0, + updatedBy: "", + labels: {}, +}); + diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.spec.gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..00cf31e8dda --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.spec.gen.ts @@ -0,0 +1,11 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// Spec is the schema of our resource. The spec should include all the user-editable information for the kind. +export interface Spec { + dummyField: number; +} + +export const defaultSpec = (): Spec => ({ + dummyField: 0, +}); + diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.status.gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/go.mod b/go.mod index e1ed6dddd56..3d277125a1f 100644 --- a/go.mod +++ b/go.mod @@ -234,6 +234,7 @@ require ( require ( github.com/grafana/grafana/apps/advisor v0.0.0 // @grafana/plugins-platform-backend github.com/grafana/grafana/apps/alerting/alertenrichment v0.0.0 // @grafana/alerting-backend + github.com/grafana/grafana/apps/alerting/historian v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/notifications v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/rules v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/annotation v0.0.0 // @grafana/grafana-backend-services-squad @@ -267,6 +268,7 @@ require ( replace ( github.com/grafana/grafana/apps/advisor => ./apps/advisor github.com/grafana/grafana/apps/alerting/alertenrichment => ./apps/alerting/alertenrichment + github.com/grafana/grafana/apps/alerting/historian => ./apps/alerting/historian github.com/grafana/grafana/apps/alerting/notifications => ./apps/alerting/notifications github.com/grafana/grafana/apps/alerting/rules => ./apps/alerting/rules github.com/grafana/grafana/apps/annotation => ./apps/annotation diff --git a/pkg/registry/apps/alerting/historian/handlers.go b/pkg/registry/apps/alerting/historian/handlers.go new file mode 100644 index 00000000000..f24323684f9 --- /dev/null +++ b/pkg/registry/apps/alerting/historian/handlers.go @@ -0,0 +1,60 @@ +package historian + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-plugin-sdk-go/data" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/ngalert/api" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type Historian interface { + Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) +} + +type handlers struct { + historian Historian +} + +func (h handlers) GetAlertStateHistoryHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error { + user, err := identity.GetRequester(ctx) + if err != nil { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusUnauthorized, + Message: "authentication required", + }} + } + + query, err := api.ParseHistoryQuery(user.GetOrgID(), user, request.URL.Query()) + if err != nil { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusBadRequest, + Message: err.Error(), + }} + } + + frame, err := h.historian.Query(ctx, query) + if err != nil { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusInternalServerError, + Message: err.Error(), + }} + } + + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + return json.NewEncoder(writer).Encode(frame) +} diff --git a/pkg/registry/apps/alerting/historian/handlers_test.go b/pkg/registry/apps/alerting/historian/handlers_test.go new file mode 100644 index 00000000000..30a7f2ba764 --- /dev/null +++ b/pkg/registry/apps/alerting/historian/handlers_test.go @@ -0,0 +1,309 @@ +package historian + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type mockHistorian struct { + queryFunc func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) +} + +func (m *mockHistorian) Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + if m.queryFunc != nil { + return m.queryFunc(ctx, query) + } + return nil, errors.New("not implemented") +} + +type mockResponseWriter struct { + *httptest.ResponseRecorder + headers http.Header +} + +func newMockResponseWriter() *mockResponseWriter { + return &mockResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + headers: make(http.Header), + } +} + +func (m *mockResponseWriter) Header() http.Header { + return m.headers +} + +func TestGetAlertStateHistoryHandler(t *testing.T) { + t.Run("returns data frame when query succeeds", func(t *testing.T) { + now := time.Now() + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{now, now.Add(time.Second)}), + data.NewField("Line", nil, []string{"alert fired", "alert resolved"}), + ) + + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + assert.Equal(t, int64(123), query.OrgID) + assert.NotNil(t, query.SignedInUser) + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{ + OrgID: 123, + }) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, writer.Code) + assert.Equal(t, "application/json", writer.headers.Get("Content-Type")) + + var result *data.Frame + err = json.Unmarshal(writer.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, "test", result.Name) + assert.Equal(t, 2, result.Rows()) + }) + + t.Run("passes query parameters to historian", func(t *testing.T) { + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{time.Now()}), + data.NewField("Line", nil, []string{"test"}), + ) + + var capturedQuery models.HistoryQuery + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + capturedQuery = query + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 99}) + + params := url.Values{} + params.Set("ruleUID", "rule-123") + params.Set("dashboardUID", "dash-456") + params.Set("panelID", "7") + params.Set("from", "1000") + params.Set("to", "2000") + params.Set("limit", "50") + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: params.Encode()}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, "rule-123", capturedQuery.RuleUID) + assert.Equal(t, "dash-456", capturedQuery.DashboardUID) + assert.Equal(t, int64(7), capturedQuery.PanelID) + assert.Equal(t, time.Unix(1000, 0), capturedQuery.From) + assert.Equal(t, time.Unix(2000, 0), capturedQuery.To) + assert.Equal(t, 50, capturedQuery.Limit) + }) + + t.Run("handles label matchers in query", func(t *testing.T) { + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{time.Now()}), + data.NewField("Line", nil, []string{"test"}), + ) + + var capturedQuery models.HistoryQuery + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + capturedQuery = query + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + params := url.Values{} + params.Add("labels", `env=prod`) + params.Add("labels", `region=us-west`) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: params.Encode()}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + if len(capturedQuery.Labels) > 0 { + assert.NotEmpty(t, capturedQuery.Labels) + } + }) + + t.Run("returns unauthorized when no user in context", func(t *testing.T) { + h := handlers{historian: &mockHistorian{}} + ctx := context.Background() + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication required") + }) + + t.Run("returns internal error when historian query fails", func(t *testing.T) { + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + return nil, errors.New("database connection failed") + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.Error(t, err) + assert.Contains(t, err.Error(), "database connection failed") + }) + + t.Run("returns empty frame when no results", func(t *testing.T) { + emptyFrame := data.NewFrame("empty") + + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + return emptyFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, writer.Code) + + var result *data.Frame + err = json.Unmarshal(writer.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, 0, result.Rows()) + }) + + t.Run("encodes complex data frame with multiple fields", func(t *testing.T) { + now := time.Now() + complexFrame := data.NewFrame("complex", + data.NewField("Time", nil, []time.Time{now}), + data.NewField("Line", nil, []string{"alert fired"}), + data.NewField("Value", nil, []float64{42.5}), + data.NewField("Labels", nil, []string{`{"env":"prod"}`}), + ) + + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + return complexFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, writer.Code) + + var result *data.Frame + err = json.Unmarshal(writer.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, 4, len(result.Fields)) + assert.Equal(t, 1, result.Rows()) + }) +} + +func TestParseHistoryQueryIntegration(t *testing.T) { + t.Run("parses all supported query parameters", func(t *testing.T) { + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{time.Now()}), + data.NewField("Line", nil, []string{"test"}), + ) + + var capturedQuery models.HistoryQuery + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + capturedQuery = query + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 5}) + + params := url.Values{} + params.Set("ruleUID", "test-rule") + params.Set("dashboardUID", "test-dash") + params.Set("panelID", "3") + params.Set("from", "1609459200") + params.Set("to", "1609545600") + params.Set("limit", "100") + params.Set("current", "alerting") + params.Set("previous", "normal") + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: params.Encode()}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, int64(5), capturedQuery.OrgID) + assert.Equal(t, "test-rule", capturedQuery.RuleUID) + assert.Equal(t, "test-dash", capturedQuery.DashboardUID) + assert.Equal(t, int64(3), capturedQuery.PanelID) + assert.Equal(t, time.Unix(1609459200, 0), capturedQuery.From) + assert.Equal(t, time.Unix(1609545600, 0), capturedQuery.To) + assert.Equal(t, 100, capturedQuery.Limit) + assert.Equal(t, "alerting", capturedQuery.Current) + assert.Equal(t, "normal", capturedQuery.Previous) + }) +} diff --git a/pkg/registry/apps/alerting/historian/register.go b/pkg/registry/apps/alerting/historian/register.go new file mode 100644 index 00000000000..fb0e90e7062 --- /dev/null +++ b/pkg/registry/apps/alerting/historian/register.go @@ -0,0 +1,58 @@ +package historian + +import ( + "github.com/grafana/grafana-app-sdk/app" + appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" + "github.com/grafana/grafana-app-sdk/simple" + restclient "k8s.io/client-go/rest" + + "github.com/grafana/grafana/apps/alerting/historian/pkg/apis" + historianApp "github.com/grafana/grafana/apps/alerting/historian/pkg/app" + historianAppConfig "github.com/grafana/grafana/apps/alerting/historian/pkg/app/config" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + _ appsdkapiserver.AppInstaller = (*AlertingHistorianAppInstaller)(nil) +) + +type AlertingHistorianAppInstaller struct { + appsdkapiserver.AppInstaller +} + +func RegisterAppInstaller( + cfg *setting.Cfg, + ng *ngalert.AlertNG, +) (*AlertingHistorianAppInstaller, error) { + if ng.IsDisabled() { + log.New("app-registry").Info("Skipping Kubernetes Alerting Historian apiserver (historian.alerting.grafana.app): Unified Alerting is disabled") + return nil, nil + } + + installer := &AlertingHistorianAppInstaller{} + + handlers := &handlers{ + historian: ng.Api.Historian, + } + + appSpecificConfig := historianAppConfig.RuntimeConfig{ + GetAlertStateHistoryHandler: handlers.GetAlertStateHistoryHandler, + } + + provider := simple.NewAppProvider(apis.LocalManifest(), appSpecificConfig, historianApp.New) + + appConfig := app.Config{ + KubeConfig: restclient.Config{}, + ManifestData: *apis.LocalManifest().ManifestData, + SpecificConfig: appSpecificConfig, + } + + i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{}) + if err != nil { + return nil, err + } + installer.AppInstaller = i + return installer, nil +} diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index 983605dedbc..9ea31109495 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/registry/apps/advisor" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -42,6 +43,7 @@ func ProvideAppInstallers( annotationAppInstaller *annotation.AnnotationAppInstaller, exampleAppInstaller *example.ExampleAppInstaller, advisorAppInstaller *advisor.AdvisorAppInstaller, + alertingHistorianAppInstaller *historian.AlertingHistorianAppInstaller, ) []appsdkapiserver.AppInstaller { installers := []appsdkapiserver.AppInstaller{ playlistAppInstaller, @@ -75,6 +77,10 @@ func ProvideAppInstallers( if features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) { installers = append(installers, advisorAppInstaller) } + //nolint:staticcheck // not yet migrated to OpenFeature + if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAlertingHistorian) && alertingHistorianAppInstaller != nil { + installers = append(installers, alertingHistorianAppInstaller) + } return installers } diff --git a/pkg/registry/apps/apps_test.go b/pkg/registry/apps/apps_test.go index b7f6c2565e6..6a6f0c9a2aa 100644 --- a/pkg/registry/apps/apps_test.go +++ b/pkg/registry/apps/apps_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/registry/apps/advisor" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -25,6 +26,8 @@ func TestProvideAppInstallers_Table(t *testing.T) { annotationAppInstaller := &annotation.AnnotationAppInstaller{} exampleAppInstaller := &example.ExampleAppInstaller{} advisorAppInstaller := &advisor.AdvisorAppInstaller{} + historianAppInstaller := &historian.AlertingHistorianAppInstaller{} + tests := []struct { name string flags []any @@ -40,7 +43,7 @@ func TestProvideAppInstallers_Table(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { features := featuremgmt.WithFeatures(tt.flags...) - got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) + got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, historianAppInstaller) if tt.expectRulesApp { require.Contains(t, got, tt.rulesInst) } else { diff --git a/pkg/registry/apps/wireset.go b/pkg/registry/apps/wireset.go index fb403ae4df7..cc6953e5dce 100644 --- a/pkg/registry/apps/wireset.go +++ b/pkg/registry/apps/wireset.go @@ -3,6 +3,7 @@ package appregistry import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -25,6 +26,7 @@ var WireSet = wire.NewSet( correlations.RegisterAppInstaller, rules.RegisterAppInstaller, notifications.RegisterAppInstaller, + historian.RegisterAppInstaller, logsdrilldown.RegisterAppInstaller, annotation.RegisterAppInstaller, example.RegisterAppInstaller, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 75341e77f78..3304a905fc0 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -80,6 +80,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/userstorage" "github.com/grafana/grafana/pkg/registry/apps" advisor2 "github.com/grafana/grafana/pkg/registry/apps/advisor" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" notifications2 "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -825,7 +826,11 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) + alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG) + if err != nil { + return nil, err + } + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { @@ -1475,7 +1480,11 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) + alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG) + if err != nil { + return nil, err + } + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 59ad50e79e2..ea80af0831e 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3633,4 +3633,4 @@ } } ] -} \ No newline at end of file +} From 49175bb2cb486977b78462cb80e04edbd9e7461c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 28 Nov 2025 11:27:48 +0000 Subject: [PATCH 31/31] Chore: Run some React 19 codemods (#114575) running react 19 codemods --- .../grafana-data/src/utils/OptionsUIBuilders.ts | 1 + .../src/configuration/AlertingSettingsOverhaul.tsx | 1 + .../src/configuration/shared/utils.tsx | 1 + .../src/services/pluginExtensions/utils.test.tsx | 2 +- .../src/components/BarGauge/BarGauge.tsx | 2 +- .../src/components/BigValue/BigValueLayout.tsx | 2 +- .../src/components/Button/Button.test.tsx | 1 + .../CallToActionCard/CallToActionCard.story.tsx | 1 + .../CallToActionCard/CallToActionCard.tsx | 1 + .../ClipboardButton/ClipboardButton.test.tsx | 1 + .../src/components/Collapse/Collapse.test.tsx | 1 + .../components/ColorPicker/NamedColorsPalette.tsx | 1 + .../src/components/ConfirmModal/ConfirmModal.tsx | 1 + .../src/components/ContextMenu/WithContextMenu.tsx | 2 +- .../components/CustomScrollbar/CustomScrollbar.tsx | 2 +- .../components/DataLinks/DataLinksContextMenu.tsx | 2 +- .../DataSourceSettings/AlertingSettings.tsx | 2 ++ .../SecureSocksProxySettings.tsx | 2 ++ .../components/DateTimePickers/TimeRangePicker.tsx | 2 +- .../TimeZonePicker/TimeZoneOption.tsx | 2 +- .../DateTimePickers/utils/useTimeSync.tsx | 2 +- .../EmptySearchResult/EmptySearchResult.tsx | 1 + .../src/components/FilterPill/FilterPill.test.tsx | 1 + .../grafana-ui/src/components/Forms/FieldArray.tsx | 2 +- .../src/components/Forms/Legacy/Input/Input.tsx | 2 +- packages/grafana-ui/src/components/Gauge/Gauge.tsx | 4 +++- .../grafana-ui/src/components/InfoBox/InfoBox.tsx | 1 + .../src/components/List/AbstractList.tsx | 2 +- packages/grafana-ui/src/components/Modal/Modal.tsx | 2 +- .../src/components/Pagination/Pagination.tsx | 2 +- .../components/PanelChrome/PanelDescription.tsx | 1 + .../src/components/QueryField/QueryField.tsx | 4 +++- .../RenderUserContentAsHTML.tsx | 2 +- .../src/components/Segment/SegmentInput.story.tsx | 2 +- .../src/components/Select/InputControl.tsx | 2 +- .../src/components/Select/MultiValue.tsx | 1 + .../src/components/Select/SelectMenu.tsx | 2 +- packages/grafana-ui/src/components/Select/types.ts | 1 + .../TabbedContainer/TabbedContainer.test.tsx | 1 + .../src/components/Table/Cells/GeoCell.tsx | 1 + .../src/components/Table/Cells/JSONViewCell.tsx | 2 +- .../src/components/Table/TableNG/TableNG.tsx | 2 +- .../grafana-ui/src/components/Tabs/Tabs.test.tsx | 1 + .../grafana-ui/src/components/Tags/Tag.test.tsx | 1 + .../src/components/Tags/TagList.test.tsx | 1 + .../src/components/Toggletip/Toggletip.tsx | 2 +- .../grafana-ui/src/components/Toggletip/types.ts | 1 + .../src/components/Tooltip/PopoverController.tsx | 2 +- .../grafana-ui/src/components/Tooltip/Tooltip.tsx | 2 +- .../grafana-ui/src/components/Tooltip/types.ts | 1 + .../components/ValuePicker/ValuePicker.test.tsx | 1 + .../src/components/VizLegend/VizLegendTable.tsx | 1 + .../grafana-ui/src/components/VizLegend/types.ts | 1 + .../src/components/VizRepeater/VizRepeater.tsx | 2 +- packages/grafana-ui/src/graveyard/Graph/Graph.tsx | 6 ++++-- .../src/graveyard/Graph/GraphSeriesToggler.tsx | 2 +- public/app/core/components/Animations/FadeIn.tsx | 2 +- .../AppChrome/AppChromeExtensionPoint.tsx | 2 ++ .../AppChrome/MegaMenu/FeatureHighlight.tsx | 1 + public/app/core/components/Branding/Branding.tsx | 2 +- .../core/components/FolderFilter/FolderFilter.tsx | 2 +- .../core/components/Layers/LayerDragDropList.tsx | 1 + public/app/core/components/Login/LoginCtrl.tsx | 2 +- .../components/PanelTypeFilter/PanelTypeFilter.tsx | 2 +- .../app/core/components/RolePicker/RolePicker.tsx | 2 +- .../core/components/RolePicker/RolePickerInput.tsx | 2 +- .../core/components/RolePicker/RolePickerMenu.tsx | 2 +- .../components/RolePicker/RolePickerSubMenu.tsx | 1 + .../app/core/components/Select/OrgPicker.test.tsx | 1 + public/app/core/components/help/HelpModal.tsx | 2 +- public/app/features/admin/ServerStatsCard.tsx | 1 + .../alerting/unified/RedirectToRuleViewer.tsx | 2 +- .../unified/components/ConditionalWrap.tsx | 2 +- .../components/GrafanaAlertmanagerWarning.test.tsx | 1 + .../unified/components/WithReturnButton.tsx | 2 +- .../unified/components/common/DetailText.tsx | 2 ++ .../contact-points/ContactPointHeader.tsx | 2 +- .../contact-points/EditContactPoint.test.tsx | 1 + .../contact-points/useExportContactPoint.tsx | 2 +- .../extensions/AlertingRuleExtensionPointMenu.tsx | 2 +- .../mute-timings/useExportMuteTimingsDrawer.tsx | 2 +- .../components/notification-policies/Modals.tsx | 2 +- .../notification-policies/Policy.test.tsx | 1 + .../components/notification-policies/Policy.tsx | 2 +- .../components/permissions/ManagePermissions.tsx | 2 +- .../components/receivers/TemplateDataDocs.tsx | 1 + .../components/receivers/TemplatePreview.test.tsx | 2 +- .../components/receivers/TemplatePreview.tsx | 1 + .../components/receivers/form/ChannelOptions.tsx | 1 + .../components/receivers/form/ChannelSubForm.tsx | 2 +- .../receivers/form/fields/DeletedSubform.tsx | 2 +- .../rule-editor/AnnotationsStep.test.tsx | 1 + .../rule-editor/CloudRulesSourcePicker.tsx | 2 +- .../components/rule-editor/NeedHelpInfo.tsx | 1 + .../unified/components/rule-viewer/DeleteModal.tsx | 2 +- .../components/rule-viewer/RuleViewerLayout.tsx | 1 + .../rule-viewer/RuleViewerVisualization.tsx | 2 ++ .../components/rules/RuleActionsButtons.tsx | 2 +- .../components/rules/RuleDetailsAnnotations.tsx | 1 + .../components/rules/RuleDetailsButtons.tsx | 2 +- .../components/rules/RuleDetailsDataSources.tsx | 2 +- .../components/rules/RuleDetailsExpression.tsx | 1 + .../unified/components/rules/RuleListErrors.tsx | 2 +- .../components/settings/AlertmanagerConfig.tsx | 2 +- .../rule-list/components/RuleActionsButtons.V2.tsx | 2 +- public/app/features/auth-config/ErrorContainer.tsx | 1 + .../AutoGridLayoutManagerEditor.tsx | 8 ++++++-- .../scene/layout-tabs/TabItemRenderer.tsx | 4 +++- .../editors/DataSourceVariableEditor.test.tsx | 1 + .../editors/IntervalVariableEditor.test.tsx | 1 + .../DashboardSettings/GeneralSettings.tsx | 2 +- .../components/PanelEditor/PanelHeaderCorner.tsx | 2 +- .../components/PanelEditor/PanelNotSupported.tsx | 2 +- .../components/ShareModal/ViewJsonModal.tsx | 2 +- .../components/SubMenu/AnnotationPicker.tsx | 2 +- .../DashboardEmptyExtensionPoint.tsx | 2 ++ .../app/features/dashboard/dashgrid/PanelLinks.tsx | 1 + .../datasources/components/DataSourceAddButton.tsx | 2 +- public/app/features/explore/Explore.tsx | 4 +++- .../app/features/explore/Logs/LogsSamplePanel.tsx | 2 +- public/app/features/explore/MetaInfoText.tsx | 2 +- .../TracePageHeader/SpanGraph/Scrubber.test.tsx | 1 + .../components/TraceTimelineViewer/index.tsx | 4 +++- .../extensions/ToolbarExtensionPointMenu.tsx | 2 +- .../expressions/components/QueryToolbox.tsx | 2 +- .../ChangeLibraryPanelModal.tsx | 2 ++ .../LibraryPanelCard/LibraryPanelCard.tsx | 2 +- .../LibraryPanelsSearch/LibraryPanelsSearch.tsx | 2 +- .../OpenLibraryPanelModal.tsx | 2 +- public/app/features/logs/components/LogLabels.tsx | 2 +- .../components/fieldSelector/AvailableFields.tsx | 2 +- .../components/panel/LogListControlsOption.tsx | 2 +- .../DeletePublicDashboardButton.tsx | 1 + .../admin/components/PluginDetailsBody.test.tsx | 1 + .../plugins/admin/components/PluginDetailsBody.tsx | 2 +- .../plugins/admin/components/PluginSubtitle.tsx | 2 +- .../admin/components/VersionInstallButton.test.tsx | 1 + .../plugins/admin/components/VersionList.test.tsx | 1 + .../features/plugins/admin/pages/PluginDetails.tsx | 1 + .../extensions/registry/useRegistrySlice.test.tsx | 2 +- .../plugins/extensions/usePluginComponent.test.tsx | 1 + .../extensions/usePluginComponents.test.tsx | 2 +- .../plugins/extensions/usePluginComponents.tsx | 2 +- .../plugins/extensions/usePluginFunctions.test.tsx | 1 + .../plugins/extensions/usePluginLinks.test.tsx | 1 + .../Wizard/ProvisioningWizard.test.tsx | 1 + .../query/components/QueryActionComponent.ts | 2 ++ .../features/query/components/QueryEditorRow.tsx | 2 +- .../serviceaccounts/ServiceAccountCreatePage.tsx | 2 +- .../serviceaccounts/ServiceAccountPage.tsx | 2 +- .../ServiceAccountsListPage.test.tsx | 1 + .../serviceaccounts/ServiceAccountsListPage.tsx | 2 +- .../components/ServiceAccountProfile.tsx | 2 +- .../components/ServiceAccountProfileRow.tsx | 2 +- .../components/ServiceAccountRoleRow.tsx | 2 ++ .../components/ServiceAccountTokensTable.tsx | 1 + .../support-bundles/SupportBundlesCreate.tsx | 2 +- public/app/features/users/UsersActionBar.tsx | 1 + .../datasource/DataSourceVariableEditor.test.tsx | 1 + .../variables/inspect/NetworkGraphModal.tsx | 2 +- .../ConfigEditor/AzureCredentialsForm.tsx | 2 +- .../CurrentUserFallbackCredentials.tsx | 2 +- .../components/TracesQueryEditor/Filter.tsx | 2 +- .../SecureSocksProxySettingsNewStyling.tsx | 2 ++ .../LogsQueryEditor/LogsQueryEditor.tsx | 2 +- .../MetricsQueryEditor/MetricsQueryEditor.tsx | 3 +-- .../components/QueryEditor/QueryEditor.tsx | 2 +- .../components/QueryEditor/QueryHeader.tsx | 2 ++ .../configuration/MappingsConfiguration.tsx | 2 +- .../graphite/configuration/MappingsHelp.tsx | 2 ++ .../query/influxql/QueryEditorModeSwitcher.tsx | 2 +- .../query/influxql/code/RawInfluxQLEditor.tsx | 2 +- .../editor/query/influxql/visual/AddButton.tsx | 2 ++ .../query/influxql/visual/FormatAsSection.tsx | 1 + .../editor/query/influxql/visual/FromSection.tsx | 2 ++ .../editor/query/influxql/visual/InputSection.tsx | 1 + .../query/influxql/visual/OrderByTimeSection.tsx | 1 + .../query/influxql/visual/PartListSection.tsx | 2 +- .../editor/query/influxql/visual/Seg.tsx | 2 +- .../editor/query/influxql/visual/TagsSection.tsx | 2 ++ .../influxql/visual/VisualInfluxQLEditor.test.tsx | 1 + .../query/influxql/visual/VisualInfluxQLEditor.tsx | 2 +- public/app/plugins/datasource/mssql/types.ts | 2 ++ .../configuration/ConfigEditorPackage.tsx | 1 + .../app/plugins/panel/annolist/AnnoListPanel.tsx | 2 +- .../app/plugins/panel/bargauge/BarGaugePanel.tsx | 2 +- public/app/plugins/panel/gauge/GaugePanel.tsx | 2 +- public/app/plugins/panel/logs/LogsPanel.tsx | 14 +++++++++++--- public/app/plugins/panel/nodeGraph/EdgeLabel.tsx | 2 +- .../app/plugins/panel/nodeGraph/useContextMenu.tsx | 2 +- .../app/plugins/panel/radialbar/RadialBarPanel.tsx | 2 ++ public/app/plugins/panel/stat/StatPanel.tsx | 2 +- public/app/routes/RoutesWrapper.tsx | 2 +- 193 files changed, 238 insertions(+), 116 deletions(-) diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index b88f633ca5e..78a80efb6e1 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -1,4 +1,5 @@ import { set, cloneDeep } from 'lodash'; +import type { JSX } from 'react'; import { FieldNamePickerConfigSettings, diff --git a/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx b/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx index 94ea6281492..3551cdcb336 100644 --- a/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx +++ b/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx @@ -1,5 +1,6 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/configuration/AlertingSettingsOverhaul.tsx import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/packages/grafana-prometheus/src/configuration/shared/utils.tsx b/packages/grafana-prometheus/src/configuration/shared/utils.tsx index 699d0e7f706..b0a1638cab3 100644 --- a/packages/grafana-prometheus/src/configuration/shared/utils.tsx +++ b/packages/grafana-prometheus/src/configuration/shared/utils.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx index b57a795c3b5..b62ae46f27c 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react'; -import React from 'react'; +import React, { type JSX } from 'react'; import { ComponentTypeWithExtensionMeta, diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 639bf41ca13..8478081182c 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -1,6 +1,6 @@ // Library import { cx } from '@emotion/css'; -import { CSSProperties, PureComponent, ReactNode } from 'react'; +import { CSSProperties, PureComponent, ReactNode, type JSX } from 'react'; import * as React from 'react'; import tinycolor from 'tinycolor2'; diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx index d13408d3271..8eee4b3ad12 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -1,4 +1,4 @@ -import { CSSProperties } from 'react'; +import { CSSProperties, type JSX } from 'react'; import * as React from 'react'; import tinycolor from 'tinycolor2'; diff --git a/packages/grafana-ui/src/components/Button/Button.test.tsx b/packages/grafana-ui/src/components/Button/Button.test.tsx index b78b9aafe5d..d82291f3263 100644 --- a/packages/grafana-ui/src/components/Button/Button.test.tsx +++ b/packages/grafana-ui/src/components/Button/Button.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Button, LinkButton } from './Button'; diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx index 8c107a20129..e46107b6b02 100644 --- a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx @@ -1,5 +1,6 @@ import { action } from '@storybook/addon-actions'; import { StoryFn, Meta } from '@storybook/react'; +import type { JSX } from 'react'; import { Button } from '../Button/Button'; diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx index cd859f77256..7b63d5cf296 100644 --- a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx index 0cf2a5ee010..224bc6bb2b9 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { ClipboardButton } from './ClipboardButton'; diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx index 817df10153d..f68093ce9f5 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Collapse } from './Collapse'; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx index 5fd64e37772..f3cdb9e07b1 100644 --- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx index b57f9d8557b..e4125db7204 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx @@ -1,5 +1,6 @@ import { css, cx } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { IconName } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx index 2f145e26baf..d439938d26e 100644 --- a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type JSX } from 'react'; import * as React from 'react'; import { ContextMenu } from '../ContextMenu/ContextMenu'; diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 77cd0b63579..82215940405 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { RefCallback, useCallback, useEffect, useRef } from 'react'; +import { RefCallback, useCallback, useEffect, useRef, type JSX } from 'react'; import * as React from 'react'; import Scrollbars, { positionValues } from 'react-custom-scrollbars-2'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx index dc85dca5464..5e3b71cbe73 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { CSSProperties } from 'react'; +import { CSSProperties, type JSX } from 'react'; import * as React from 'react'; import { ActionModel, GrafanaTheme2, LinkModel } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx index 98570eb2e15..4ec3bc939c2 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx index d8708ddb470..0e6b9796afa 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index 0719693e625..459a7d56e1c 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; -import { memo, createRef, useState, useEffect } from 'react'; +import { memo, createRef, useState, useEffect, type JSX } from 'react'; import { rangeUtil, diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx index cf3a2dec556..7957587508d 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import { isString } from 'lodash'; -import { PropsWithChildren, RefCallback } from 'react'; +import { PropsWithChildren, RefCallback, type JSX } from 'react'; import * as React from 'react'; import { GrafanaTheme2, SelectableValue, getTimeZoneInfo } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx b/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx index 3aa15a7fb98..89d151b689f 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, type JSX } from 'react'; import { usePrevious } from 'react-use'; import { TimeRange } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx b/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx index 72f353b4ed7..4373ea2f94b 100644 --- a/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx +++ b/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx b/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx index 1fff175840f..eae37775224 100644 --- a/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx +++ b/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { FilterPill } from './FilterPill'; diff --git a/packages/grafana-ui/src/components/Forms/FieldArray.tsx b/packages/grafana-ui/src/components/Forms/FieldArray.tsx index 5c21767ab84..ad8f18b16c2 100644 --- a/packages/grafana-ui/src/components/Forms/FieldArray.tsx +++ b/packages/grafana-ui/src/components/Forms/FieldArray.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, type JSX } from 'react'; import { useFieldArray, UseFieldArrayProps } from 'react-hook-form'; import { FieldArrayApi } from '../../types/forms'; diff --git a/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx b/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx index a45a854a045..5bcd70c2585 100644 --- a/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx @@ -14,7 +14,7 @@ export enum LegacyInputStatus { export interface Props extends React.HTMLProps { validationEvents?: ValidationEvents; hideErrorMessage?: boolean; - inputRef?: React.LegacyRef; + inputRef?: React.Ref; // Override event props and append status as argument onBlur?: (event: React.FocusEvent, status?: LegacyInputStatus) => void; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 8c050c109c0..133826864ec 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -168,7 +168,9 @@ export class Gauge extends PureComponent { const gaugeElement = (
(this.canvasElement = element)} + ref={(element) => { + this.canvasElement = element; + }} /> ); diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx index bf3e0e7f69c..52e8ed8e567 100644 --- a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx @@ -1,5 +1,6 @@ import { css, cx } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/List/AbstractList.tsx b/packages/grafana-ui/src/components/List/AbstractList.tsx index 920b6cf0b5b..de1d360800f 100644 --- a/packages/grafana-ui/src/components/List/AbstractList.tsx +++ b/packages/grafana-ui/src/components/List/AbstractList.tsx @@ -1,5 +1,5 @@ import { cx, css } from '@emotion/css'; -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import { stylesFactory } from '../../themes/stylesFactory'; diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index fc00bc9e562..aaeb2c3e426 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -2,7 +2,7 @@ import { cx } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import { PropsWithChildren, useRef } from 'react'; +import { PropsWithChildren, useRef, type JSX } from 'react'; import * as React from 'react'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/Pagination/Pagination.tsx b/packages/grafana-ui/src/components/Pagination/Pagination.tsx index c0c25d4186b..57afc826b93 100644 --- a/packages/grafana-ui/src/components/Pagination/Pagination.tsx +++ b/packages/grafana-ui/src/components/Pagination/Pagination.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx index f66ca51bb86..608795b219b 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/QueryField/QueryField.tsx b/packages/grafana-ui/src/components/QueryField/QueryField.tsx index 053bddb3ad3..1ce8a68dc87 100644 --- a/packages/grafana-ui/src/components/QueryField/QueryField.tsx +++ b/packages/grafana-ui/src/components/QueryField/QueryField.tsx @@ -211,7 +211,9 @@ export class UnThemedQueryField extends PureComponent
(this.editor = editor!)} + ref={(editor) => { + this.editor = editor!; + }} schema={SCHEMA} autoCorrect={false} readOnly={this.props.disabled} diff --git a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx index 3fb806bc162..456d5044dd3 100644 --- a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx +++ b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes, PropsWithChildren } from 'react'; +import { HTMLAttributes, PropsWithChildren, type JSX } from 'react'; import * as React from 'react'; import { textUtil } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx b/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx index d8278697334..c0a829dc760 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx @@ -1,6 +1,6 @@ import { action } from '@storybook/addon-actions'; import { Meta, StoryFn } from '@storybook/react'; -import { useState } from 'react'; +import { useState, type JSX } from 'react'; import * as React from 'react'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/Select/InputControl.tsx b/packages/grafana-ui/src/components/Select/InputControl.tsx index 4635b6a27dc..f45ba1c74b3 100644 --- a/packages/grafana-ui/src/components/Select/InputControl.tsx +++ b/packages/grafana-ui/src/components/Select/InputControl.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { forwardRef } from 'react'; +import { forwardRef, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Select/MultiValue.tsx b/packages/grafana-ui/src/components/Select/MultiValue.tsx index 987d34a1290..31bd3d23d91 100644 --- a/packages/grafana-ui/src/components/Select/MultiValue.tsx +++ b/packages/grafana-ui/src/components/Select/MultiValue.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index bfb8b17a1c1..37bd1452e91 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import { max } from 'lodash'; -import { RefCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import { RefCallback, useLayoutEffect, useMemo, useRef, type JSX } from 'react'; import * as React from 'react'; import { FixedSizeList as List } from 'react-window'; diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 3512ab9fbee..6ed93a5c0e5 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { ActionMeta as SelectActionMeta, CommonProps as ReactSelectCommonProps, diff --git a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx index c28e2b37a39..5d6a6e4984a 100644 --- a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx +++ b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { IconName } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx b/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx index 16d37fc3f4e..6328b157b29 100644 --- a/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx @@ -1,5 +1,6 @@ import WKT from 'ol/format/WKT'; import { Geometry } from 'ol/geom'; +import type { JSX } from 'react'; import { TableCellProps } from '../types'; diff --git a/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx index 6e8b678d528..c9476244bcf 100644 --- a/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import { isString } from 'lodash'; -import { useState } from 'react'; +import { useState, type JSX } from 'react'; import { getCellLinks } from '../../../utils/table'; import { CellActions } from '../CellActions'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index a3772785d63..798ca4e1ae0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -2,7 +2,7 @@ import 'react-data-grid/lib/styles.css'; import { clsx } from 'clsx'; import memoize from 'micro-memoize'; -import { CSSProperties, Key, ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { CSSProperties, Key, ReactNode, useCallback, useMemo, useRef, useState, type JSX } from 'react'; import { Cell, CellRendererProps, diff --git a/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx b/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx index 91b0aabce60..1caafa6ef44 100644 --- a/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Tab } from './Tab'; import { TabsBar } from './TabsBar'; diff --git a/packages/grafana-ui/src/components/Tags/Tag.test.tsx b/packages/grafana-ui/src/components/Tags/Tag.test.tsx index 48fce39b162..b5535284105 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.test.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Tag } from './Tag'; diff --git a/packages/grafana-ui/src/components/Tags/TagList.test.tsx b/packages/grafana-ui/src/components/Tags/TagList.test.tsx index 7c0d8a0314b..f8a4a393c88 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.test.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { TagList } from './TagList'; diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx index 41cc4e55ab4..002f15ec04b 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx @@ -11,7 +11,7 @@ import { useInteractions, } from '@floating-ui/react'; import { Placement } from '@popperjs/core'; -import { memo, cloneElement, isValidElement, useRef, useState } from 'react'; +import { memo, cloneElement, isValidElement, useRef, useState, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/Toggletip/types.ts b/packages/grafana-ui/src/components/Toggletip/types.ts index 62d0335a868..ff730df05b2 100644 --- a/packages/grafana-ui/src/components/Toggletip/types.ts +++ b/packages/grafana-ui/src/components/Toggletip/types.ts @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; export interface ToggletipContentProps { /** * @deprecated diff --git a/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx b/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx index 7128a67528a..1e792aa95d8 100644 --- a/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx +++ b/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx @@ -1,5 +1,5 @@ import { Placement } from '@popperjs/core'; -import { Component } from 'react'; +import { Component, type JSX } from 'react'; import { PopoverContent } from './types'; diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 665e2d7bf6c..59902c5c953 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -10,7 +10,7 @@ import { useInteractions, safePolygon, } from '@floating-ui/react'; -import { forwardRef, cloneElement, isValidElement, useCallback, useId, useRef, useState } from 'react'; +import { forwardRef, cloneElement, isValidElement, useCallback, useId, useRef, useState, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/packages/grafana-ui/src/components/Tooltip/types.ts b/packages/grafana-ui/src/components/Tooltip/types.ts index ac01b7f5409..dcdede5e19c 100644 --- a/packages/grafana-ui/src/components/Tooltip/types.ts +++ b/packages/grafana-ui/src/components/Tooltip/types.ts @@ -1,4 +1,5 @@ import { Placement } from '@floating-ui/react'; +import type { JSX } from 'react'; export interface PopoverContentProps { /** diff --git a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx index e5cf2ccce1d..42f6d57c79e 100644 --- a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx +++ b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { ValuePicker } from './ValuePicker'; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 0383aa1eab1..b0f578fae79 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/VizLegend/types.ts b/packages/grafana-ui/src/components/VizLegend/types.ts index 4032276bf6a..3c0de880792 100644 --- a/packages/grafana-ui/src/components/VizLegend/types.ts +++ b/packages/grafana-ui/src/components/VizLegend/types.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { DataFrameFieldIndex, DisplayValue } from '@grafana/data'; import { LegendDisplayMode, LegendPlacement, LineStyle } from '@grafana/schema'; diff --git a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx index 8a42baf89d7..a1fb6c6a1c3 100644 --- a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx +++ b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx @@ -1,5 +1,5 @@ import { clamp } from 'lodash'; -import { PureComponent, CSSProperties } from 'react'; +import { PureComponent, CSSProperties, type JSX } from 'react'; import * as React from 'react'; import { VizOrientation } from '@grafana/data'; diff --git a/packages/grafana-ui/src/graveyard/Graph/Graph.tsx b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx index 759e041decd..d926214c3cb 100644 --- a/packages/grafana-ui/src/graveyard/Graph/Graph.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx @@ -1,7 +1,7 @@ // Libraries import $ from 'jquery'; import { uniqBy } from 'lodash'; -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import * as React from 'react'; // Types @@ -384,7 +384,9 @@ export class Graph extends PureComponent {
(this.element = e)} + ref={(e) => { + this.element = e; + }} style={{ height, width }} onMouseLeave={() => { this.setState({ isTooltipVisible: false }); diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx index 3361b077b03..8c38dc62708 100644 --- a/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx @@ -1,5 +1,5 @@ import { difference, isEqual } from 'lodash'; -import { Component } from 'react'; +import { Component, type JSX } from 'react'; import * as React from 'react'; import { GraphSeriesXY } from '@grafana/data'; diff --git a/public/app/core/components/Animations/FadeIn.tsx b/public/app/core/components/Animations/FadeIn.tsx index 80a12637f18..9570f7255e9 100644 --- a/public/app/core/components/Animations/FadeIn.tsx +++ b/public/app/core/components/Animations/FadeIn.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, useRef } from 'react'; +import { CSSProperties, useRef, type JSX } from 'react'; import Transition, { ExitHandler } from 'react-transition-group/Transition'; interface Props { diff --git a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx index 96d7d68d8ad..9f3e37555d6 100644 --- a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx +++ b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { PluginExtensionPoints } from '@grafana/data'; import { config, renderLimitedComponents, usePluginComponents } from '@grafana/runtime'; import { useGrafana } from 'app/core/context/GrafanaContext'; diff --git a/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx b/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx index 6678e57a2ec..ca660e8750c 100644 --- a/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/core/components/Branding/Branding.tsx b/public/app/core/components/Branding/Branding.tsx index b32199ed4c7..e44eb62658b 100644 --- a/public/app/core/components/Branding/Branding.tsx +++ b/public/app/core/components/Branding/Branding.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { FC } from 'react'; +import { FC, type JSX } from 'react'; import { colorManipulator } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; diff --git a/public/app/core/components/FolderFilter/FolderFilter.tsx b/public/app/core/components/FolderFilter/FolderFilter.tsx index 1f8f3e5557d..6f2ba1c6c91 100644 --- a/public/app/core/components/FolderFilter/FolderFilter.tsx +++ b/public/app/core/components/FolderFilter/FolderFilter.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, type JSX } from 'react'; import { t } from '@grafana/i18n'; import { ComboboxOption, MultiCombobox } from '@grafana/ui'; diff --git a/public/app/core/components/Layers/LayerDragDropList.tsx b/public/app/core/components/Layers/LayerDragDropList.tsx index d90dfdf04af..b2eca9ca2f1 100644 --- a/public/app/core/components/Layers/LayerDragDropList.tsx +++ b/public/app/core/components/Layers/LayerDragDropList.tsx @@ -1,5 +1,6 @@ import { css, cx } from '@emotion/css'; import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index 9ea5cba50cf..1a5f3d572e4 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback } from 'react'; +import { memo, useState, useCallback, type JSX } from 'react'; import { t } from '@grafana/i18n'; import { FetchError, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; diff --git a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx index 61e3a78d800..4ab642ac551 100644 --- a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx +++ b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState, type JSX } from 'react'; import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/core/components/RolePicker/RolePicker.tsx b/public/app/core/components/RolePicker/RolePicker.tsx index 25cff0889a1..948b8af9506 100644 --- a/public/app/core/components/RolePicker/RolePicker.tsx +++ b/public/app/core/components/RolePicker/RolePicker.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useCallback, useEffect, useState, useRef } from 'react'; +import { FormEvent, useCallback, useEffect, useState, useRef, type JSX } from 'react'; import { OrgRole } from '@grafana/data'; import { ClickOutsideWrapper, Portal, useTheme2 } from '@grafana/ui'; diff --git a/public/app/core/components/RolePicker/RolePickerInput.tsx b/public/app/core/components/RolePicker/RolePickerInput.tsx index 3f8158020c3..9acdbed24d9 100644 --- a/public/app/core/components/RolePicker/RolePickerInput.tsx +++ b/public/app/core/components/RolePicker/RolePickerInput.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { FormEvent, HTMLProps, useEffect, useRef } from 'react'; +import { FormEvent, HTMLProps, useEffect, useRef, type JSX } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/core/components/RolePicker/RolePickerMenu.tsx b/public/app/core/components/RolePicker/RolePickerMenu.tsx index 81c38519ae6..4287d17c979 100644 --- a/public/app/core/components/RolePicker/RolePickerMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerMenu.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type JSX } from 'react'; import { OrgRole } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/core/components/RolePicker/RolePickerSubMenu.tsx b/public/app/core/components/RolePicker/RolePickerSubMenu.tsx index 3dff3fc88be..1254dac06a4 100644 --- a/public/app/core/components/RolePicker/RolePickerSubMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerSubMenu.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Button, ScrollContainer, Stack, useStyles2, useTheme2 } from '@grafana/ui'; diff --git a/public/app/core/components/Select/OrgPicker.test.tsx b/public/app/core/components/Select/OrgPicker.test.tsx index 38344d1fd8a..4bab94faefa 100644 --- a/public/app/core/components/Select/OrgPicker.test.tsx +++ b/public/app/core/components/Select/OrgPicker.test.tsx @@ -1,5 +1,6 @@ import { screen, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { OrgPicker } from './OrgPicker'; diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index 82f39fac28f..e334d23982e 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { useAssistant } from '@grafana/assistant'; import { FeatureState, GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/admin/ServerStatsCard.tsx b/public/app/features/admin/ServerStatsCard.tsx index 070005e7e3a..872d3844e0b 100644 --- a/public/app/features/admin/ServerStatsCard.tsx +++ b/public/app/features/admin/ServerStatsCard.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx index 96410c28320..bbcd025d1db 100644 --- a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx +++ b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { type JSX, useMemo } from 'react'; import { Navigate } from 'react-router-dom-v5-compat'; import { useLocation } from 'react-use'; diff --git a/public/app/features/alerting/unified/components/ConditionalWrap.tsx b/public/app/features/alerting/unified/components/ConditionalWrap.tsx index 55b80d9ce45..559abb737d3 100644 --- a/public/app/features/alerting/unified/components/ConditionalWrap.tsx +++ b/public/app/features/alerting/unified/components/ConditionalWrap.tsx @@ -1,4 +1,4 @@ -import { Ref, cloneElement, forwardRef } from 'react'; +import { type JSX, Ref, cloneElement, forwardRef } from 'react'; interface ConditionalWrapProps { shouldWrap: boolean; diff --git a/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.test.tsx b/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.test.tsx index 52c87f5f41c..dbfa2dbfeba 100644 --- a/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.test.tsx +++ b/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.test.tsx @@ -1,4 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; +import type { JSX } from 'react'; import { Provider } from 'react-redux'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; diff --git a/public/app/features/alerting/unified/components/WithReturnButton.tsx b/public/app/features/alerting/unified/components/WithReturnButton.tsx index 6b266949e1b..2308df8f063 100644 --- a/public/app/features/alerting/unified/components/WithReturnButton.tsx +++ b/public/app/features/alerting/unified/components/WithReturnButton.tsx @@ -1,4 +1,4 @@ -import { cloneElement, useCallback } from 'react'; +import { type JSX, cloneElement, useCallback } from 'react'; import { useReturnToPrevious } from '@grafana/runtime'; diff --git a/public/app/features/alerting/unified/components/common/DetailText.tsx b/public/app/features/alerting/unified/components/common/DetailText.tsx index f35c0765a60..296c5c79818 100644 --- a/public/app/features/alerting/unified/components/common/DetailText.tsx +++ b/public/app/features/alerting/unified/components/common/DetailText.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { t } from '@grafana/i18n'; import { Box, ClipboardButton, Stack, Text, Tooltip } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx index 1825b5e40a0..0fb1403cb35 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { Fragment, useState } from 'react'; +import { Fragment, type JSX, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx index 75413e20e33..9bc321a40a6 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx @@ -1,4 +1,5 @@ import 'core-js/stable/structured-clone'; +import type { JSX } from 'react'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { render, screen, within } from 'test/test-utils'; diff --git a/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx index 3dcb8753ccc..3638497e2a7 100644 --- a/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { type JSX, useCallback, useMemo, useState } from 'react'; import { useToggle } from 'react-use'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; diff --git a/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx b/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx index 20ef95fdb22..ef7f9411a45 100644 --- a/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx +++ b/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useMemo } from 'react'; +import { type JSX, ReactElement, useMemo } from 'react'; import { PluginExtensionLink } from '@grafana/data'; import { Menu } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx b/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx index 3d65d91aa9a..b9cc9d1af55 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { type JSX, useCallback, useMemo, useState } from 'react'; import { useToggle } from 'react-use'; import { GrafanaMuteTimingsExporter } from '../export/GrafanaMuteTimingsExporter'; diff --git a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx index d3187c41a7e..20c222749fc 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx @@ -1,5 +1,5 @@ import { groupBy } from 'lodash'; -import { FC, useCallback, useMemo, useState } from 'react'; +import { FC, type JSX, useCallback, useMemo, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Button, Icon, Modal, ModalProps, Spinner, Stack } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index f27d5503d12..a10bca42100 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -1,6 +1,7 @@ import { renderHook, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { first, noop } from 'lodash'; +import type { JSX } from 'react'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { render } from 'test/test-utils'; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index 3f80f6cd902..c4b6a0c55b7 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { isArray, sumBy, uniqueId } from 'lodash'; import pluralize from 'pluralize'; import * as React from 'react'; -import { FC, Fragment, ReactNode, useState } from 'react'; +import { FC, Fragment, type JSX, ReactNode, useState } from 'react'; import { useToggle } from 'react-use'; import { AlertLabel, getInheritedProperties } from '@grafana/alerting'; diff --git a/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx b/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx index 33ebf71d59a..8ba91403352 100644 --- a/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx +++ b/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, useState } from 'react'; +import { ComponentProps, type JSX, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Button, Drawer } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx index 4252cfde654..239b8e9abf4 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx index fd38b545945..e4e1f441860 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx @@ -1,4 +1,4 @@ -import { default as React } from 'react'; +import { type JSX, default as React } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { Provider } from 'react-redux'; import { render, screen, waitFor, within } from 'test/test-utils'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx index 5e5ecc1e08b..c4b154809b8 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx @@ -1,6 +1,7 @@ import { css, cx } from '@emotion/css'; import { compact, uniqueId } from 'lodash'; import * as React from 'react'; +import type { JSX } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx index fbdfdbbf493..16042c0e654 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { DeepMap, FieldError, FieldErrors, useFormContext } from 'react-hook-form'; import { Field, SecretInput } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index 0202e7c2e52..645c6565a01 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { sortBy } from 'lodash'; import * as React from 'react'; -import { useEffect, useMemo } from 'react'; +import { type JSX, useEffect, useMemo } from 'react'; import { Controller, FieldErrors, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx index bb5537cbbee..092e64f1da4 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { type JSX, useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; interface Props { diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx index a8f73230c21..5fb3267432a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { render, screen, within } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; diff --git a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx index 72ec6200ec5..8479b335181 100644 --- a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { type JSX, useCallback } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourcePicker, DataSourcePickerProps } from 'app/features/datasources/components/picker/DataSourcePicker'; diff --git a/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx b/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx index 04b28bd3bf4..0be96f0bdc2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index a26a9709d38..b29257a971d 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { type JSX, useCallback, useMemo, useState } from 'react'; import { t } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx index 1f61f10e640..d9c61ca8fed 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index c55b01c6d16..93f6b73feab 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { PanelData } from '@grafana/data'; import { VizWrapper } from '../rule-editor/VizWrapper'; diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 206d939811f..68aed9c2dfe 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -1,5 +1,5 @@ import { isString } from 'lodash'; -import { useState } from 'react'; +import { type JSX, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { LinkButton, Stack } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx index b2ff856a498..367ca5f5d6a 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx index 28cd663bdeb..8f733cd6df0 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react'; +import { Fragment, type JSX } from 'react'; import { textUtil } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx index 3ba34bf86fa..fc03406cffd 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { type JSX, useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx index d6d472886d4..3e60213a82f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { t } from '@grafana/i18n'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx index 58cc844d92c..6a35dbe5d0b 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { SerializedError } from '@reduxjs/toolkit'; -import { FC, ReactElement, useMemo, useState } from 'react'; +import { FC, type JSX, ReactElement, useMemo, useState } from 'react'; import { useLocalStorage } from 'react-use'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx index 8282d16ed97..f853c19357b 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; +import { type JSX, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import AutoSizer from 'react-virtualized-auto-sizer'; diff --git a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx index 393355ade61..a73d27f7165 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx @@ -1,5 +1,5 @@ import { isString } from 'lodash'; -import { useState } from 'react'; +import { type JSX, useState } from 'react'; import { RequireAtLeastOne } from 'type-fest'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/auth-config/ErrorContainer.tsx b/public/app/features/auth-config/ErrorContainer.tsx index 1a51263a364..148ee9d33da 100644 --- a/public/app/features/auth-config/ErrorContainer.tsx +++ b/public/app/features/auth-config/ErrorContainer.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Alert } from '@grafana/ui'; diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx index fb2416f437c..2ab55869429 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManagerEditor.tsx @@ -117,7 +117,9 @@ function GridLayoutColumns({ layoutManager }: { layoutManager: AutoGridLayoutMan id="min-column-width" defaultValue={columnWidth} onBlur={onCustomMinWidthChanged} - ref={(ref) => setInputRef(ref)} + ref={(ref) => { + setInputRef(ref); + }} type="number" min={50} max={2000} @@ -233,7 +235,9 @@ function GridLayoutRows({ layoutManager }: { layoutManager: AutoGridLayoutManage id="min-height" defaultValue={rowHeight} onBlur={onCustomHeightChanged} - ref={(ref) => setInputRef(ref)} + ref={(ref) => { + setInputRef(ref); + }} type="number" min={50} max={2000} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index c9af2bc439c..0fa22e9d305 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -56,7 +56,9 @@ export function TabItemRenderer({ model }: SceneComponentProps) { {(dragProvided, dragSnapshot) => (
dragProvided.innerRef(ref)} + ref={(ref) => { + dragProvided.innerRef(ref); + }} className={cx(dragSnapshot.isDragging && styles.dragging)} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps} diff --git a/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx index 44e0cb9a5e2..b376bc2b0e5 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx @@ -2,6 +2,7 @@ import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { DataSourceVariable } from '@grafana/scenes'; diff --git a/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx index ef51fde16f6..392f31ec92f 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx @@ -2,6 +2,7 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { IntervalVariable } from '@grafana/scenes'; diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index d7b32cfabb2..461052cbe2a 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, ChangeEvent, useState } from 'react'; +import { useCallback, ChangeEvent, useState, type JSX } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { TimeZone } from '@grafana/data'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx index 2ba5aec62bb..57fe4325b14 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import { GrafanaTheme2, renderMarkdown, LinkModelSupplier, ScopedVars, IconName } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx b/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx index 0e52eb1c74c..1d1b739f87a 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import { Trans } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; diff --git a/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx index 926cc15cd45..9e2b0c6f578 100644 --- a/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx index 78b04d0bef4..d4e11bdb3ae 100644 --- a/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx +++ b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { AnnotationQuery, EventBus, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyExtensionPoint.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyExtensionPoint.tsx index 5e7a551514f..f604ca3e778 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyExtensionPoint.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyExtensionPoint.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { PluginExtensionPoints } from '@grafana/data'; import { config, renderLimitedComponents, usePluginComponents } from '@grafana/runtime'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; diff --git a/public/app/features/dashboard/dashgrid/PanelLinks.tsx b/public/app/features/dashboard/dashgrid/PanelLinks.tsx index 3ad8fca5d9f..6e1a5cb2fb3 100644 --- a/public/app/features/dashboard/dashgrid/PanelLinks.tsx +++ b/public/app/features/dashboard/dashgrid/PanelLinks.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { DataLink, GrafanaTheme2, LinkModel } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/features/datasources/components/DataSourceAddButton.tsx b/public/app/features/datasources/components/DataSourceAddButton.tsx index f765188b50d..a3af80ccf6c 100644 --- a/public/app/features/datasources/components/DataSourceAddButton.tsx +++ b/public/app/features/datasources/components/DataSourceAddButton.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import { Pages } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 79678f4e15a..39c5b09dc6c 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -640,7 +640,9 @@ export class Explore extends PureComponent { )} (this.scrollElement = scrollElement || undefined)} + ref={(scrollElement) => { + this.scrollElement = scrollElement || undefined; + }} >
{datasourceInstance ? ( diff --git a/public/app/features/explore/Logs/LogsSamplePanel.tsx b/public/app/features/explore/Logs/LogsSamplePanel.tsx index 5dfba3eee39..0f99fde8063 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useRef } from 'react'; +import { useRef, type JSX } from 'react'; import { CoreApp, diff --git a/public/app/features/explore/MetaInfoText.tsx b/public/app/features/explore/MetaInfoText.tsx index d2106cb155d..8e3dfb48a3d 100644 --- a/public/app/features/explore/MetaInfoText.tsx +++ b/public/app/features/explore/MetaInfoText.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo } from 'react'; +import { memo, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx index b57f4274519..6cab9c42f80 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx @@ -13,6 +13,7 @@ // limitations under the License. import { render, screen, fireEvent, within } from '@testing-library/react'; +import type { JSX } from 'react'; import Scrubber, { ScrubberProps } from './Scrubber'; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx index 5843cdec64e..ce86d210b63 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx @@ -197,7 +197,9 @@ export class UnthemedTraceTimelineViewer extends PureComponent { return (
ref && this.setState({ height: ref.getBoundingClientRect().height })} + ref={(ref: HTMLDivElement | null) => { + ref && this.setState({ height: ref.getBoundingClientRect().height }); + }} > { return ( <> - {query.metricQueryType === MetricQueryType.Search && ( <> {query.metricEditorMode === MetricEditorMode.Builder && ( diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx index 04bf910f1d4..2999ff7f79b 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, type JSX } from 'react'; import { QueryEditorProps } from '@grafana/data'; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx index 57568a0666e..78da93ba3a1 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { CoreApp, LoadingState, QueryEditorProps, SelectableValue } from '@grafana/data'; import { EditorHeader, InlineSelect, FlexItem } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; diff --git a/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx b/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx index 0df6363a7ce..39f90596fb1 100644 --- a/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx +++ b/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useState } from 'react'; +import { ChangeEvent, useState, type JSX } from 'react'; import { Box, Button, Icon, InlineField, InlineFieldRow, Input } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx b/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx index 37a93580ca1..12a0a581e8e 100644 --- a/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx +++ b/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { Alert } from '@grafana/ui'; type Props = { diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx index 7bc5dcf6a6e..246f21c9b1c 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { Button, ConfirmModal } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx index 0098e8eb0af..5871e0ebf4c 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx @@ -1,4 +1,4 @@ -import { useId } from 'react'; +import { useId, type JSX } from 'react'; import { Stack, InlineField, Input, Select, TextArea } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx index 7123f816f22..7df491257d2 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { SelectableValue } from '@grafana/data'; import { unwrap } from '../utils/unwrap'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx index 6bf005185f4..b88cb6d1bc6 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { Select } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx index 153ec2aa039..b0dd96e711c 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { AccessoryButton } from '@grafana/plugin-ui'; import { DEFAULT_POLICY } from '../../../../../types'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx index 98c4ebf30ac..11deca0c8fe 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { Input } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx index eab1943a4ea..d2a18d280f7 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { SelectableValue } from '@grafana/data'; import { Select } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx index 14f188bd683..b02fdeeafa2 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { Fragment, useMemo } from 'react'; +import { Fragment, useMemo, type JSX } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { AccessoryButton } from '@grafana/plugin-ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx index 47b24fcecf6..2adae762b4e 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import debouncePromise from 'debounce-promise'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { useAsyncFn } from 'react-use'; import { SelectableValue } from '@grafana/data'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx index 3edc8320144..ec7a138b590 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { SelectableValue } from '@grafana/data'; import { AccessoryButton } from '@grafana/plugin-ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx index 837a56b85e4..ce6e52fde97 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx @@ -1,4 +1,5 @@ import { render, waitFor } from '@testing-library/react'; +import type { JSX } from 'react'; import InfluxDatasource from '../../../../../datasource'; import { getMockInfluxDS, getMockDSInstanceSettings } from '../../../../../mocks/datasource'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx index ddc3f82cbb9..ac1a4a8dea6 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useId, useMemo } from 'react'; +import { useId, useMemo, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { InlineLabel, SegmentSection, useStyles2 } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/mssql/types.ts b/public/app/plugins/datasource/mssql/types.ts index 22066dcdd03..c4ed9763cec 100644 --- a/public/app/plugins/datasource/mssql/types.ts +++ b/public/app/plugins/datasource/mssql/types.ts @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { AzureCredentials } from '@grafana/azure-sdk'; import { SQLOptions } from '@grafana/sql'; import { HttpSettingsBaseProps } from '@grafana/ui/internal'; diff --git a/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx b/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx index 68140a80e68..a5bfccd9d23 100644 --- a/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { SIGV4ConnectionConfig } from '@grafana/aws-sdk'; import { hasCredentials } from '@grafana/azure-sdk'; diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx index de0baf6f375..77dbc76feb3 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { createRef, PureComponent } from 'react'; +import { createRef, PureComponent, type JSX } from 'react'; import { Subscription } from 'rxjs'; import { diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index 0c59c7905c1..d8714fd8f4a 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -1,5 +1,5 @@ import { isNumber } from 'lodash'; -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import { DisplayProcessor, diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index 2d8eb4c9610..3ae1988e30a 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import { FieldDisplay, getDisplayProcessor, getFieldDisplayValues, PanelProps } from '@grafana/data'; import { BarGaugeSizing, VizOrientation } from '@grafana/schema'; diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 191e503fd90..3cf47744762 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -576,7 +576,9 @@ export const LogsPanel = ({ onMouseLeave={onLogContainerMouseLeave} className={style.logListContainer} style={height ? { minHeight: height } : undefined} - ref={(element: HTMLDivElement) => setScrollElement(element)} + ref={(element: HTMLDivElement) => { + setScrollElement(element); + }} > {deduplicatedRows.length > 0 && scrollElement && ( )} {!config.featureToggles.newLogsPanel && !showControls && ( - setScrollElement(scrollElement)}> + { + setScrollElement(scrollElement); + }} + >
{showCommonLabels && !isAscending && renderCommonLabels()} {showCommonLabels && !isAscending && renderCommonLabels()} setScrollElement(scrollElement)} + ref={(scrollElement: HTMLDivElement | null) => { + setScrollElement(scrollElement); + }} visualisationType="logs" loading={infiniteScrolling} loadMoreLogs={enableInfiniteScrolling ? loadMoreLogs : undefined} diff --git a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx index ee727b4f79b..48f871a3a10 100644 --- a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx +++ b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo } from 'react'; +import { memo, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx index 65daef5aeda..8ab331cfb3d 100644 --- a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx +++ b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { MouseEvent, useCallback, useState } from 'react'; +import { MouseEvent, useCallback, useState, type JSX } from 'react'; import * as React from 'react'; import { DataFrame, Field, GrafanaTheme2, LinkModel, LinkTarget } from '@grafana/data'; diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index c176b650c24..decd084f375 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { DisplayValueAlignmentFactors, FieldDisplay, diff --git a/public/app/plugins/panel/stat/StatPanel.tsx b/public/app/plugins/panel/stat/StatPanel.tsx index 055fc4adc93..40df4acb9eb 100644 --- a/public/app/plugins/panel/stat/StatPanel.tsx +++ b/public/app/plugins/panel/stat/StatPanel.tsx @@ -1,5 +1,5 @@ import { isNumber } from 'lodash'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, type JSX } from 'react'; import { DisplayValueAlignmentFactors, diff --git a/public/app/routes/RoutesWrapper.tsx b/public/app/routes/RoutesWrapper.tsx index dafa5b000c5..2c36d7571f9 100644 --- a/public/app/routes/RoutesWrapper.tsx +++ b/public/app/routes/RoutesWrapper.tsx @@ -1,4 +1,4 @@ -import { ComponentType, ReactNode } from 'react'; +import { ComponentType, ReactNode, type JSX } from 'react'; import { Router } from 'react-router-dom'; import { CompatRouter } from 'react-router-dom-v5-compat';