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",