Compare commits

...

23 Commits

Author SHA1 Message Date
Roberto Jimenez Sanchez
9a91abefa3 Merge remote-tracking branch 'origin/main' into provisioning/file-list 2025-11-27 11:42:33 +01:00
Clarity-89
521c32a0dd SHow pending for unsynced files 2025-11-27 12:05:56 +02:00
Clarity-89
a0bef8fe60 tweaks 2025-11-27 12:02:42 +02:00
Clarity-89
1455218569 Show external source link 2025-11-27 11:57:05 +02:00
Clarity-89
db29ea05d3 fix unsynced files type 2025-11-27 11:48:40 +02:00
Clarity-89
89b7fa914a refactor 2025-11-27 11:43:54 +02:00
Clarity-89
fa6ecff014 Fix sync folder logic 2025-11-27 10:13:38 +02:00
Clarity-89
995cf2624e refactor 2025-11-27 10:00:49 +02:00
Clarity-89
208807ab88 Merge branch 'main' into provisioning/file-list 2025-11-27 09:22:40 +02:00
Clarity-89
46586bc1a0 Show folders sync status 2025-11-27 09:21:37 +02:00
Clarity-89
ef9ce9f072 Hide source link for unsynced files 2025-11-27 09:06:08 +02:00
Clarity-89
0d095c3b7c Add source link 2025-11-27 09:04:04 +02:00
Clarity-89
c4693b9eb7 Merge branch 'main' into provisioning/file-list 2025-11-27 08:37:58 +02:00
Clarity-89
f8f6f70678 Move funciton outside 2025-11-26 18:57:20 +02:00
Clarity-89
0775009f86 Cleanup 2025-11-26 18:48:20 +02:00
Clarity-89
f402c82d1c Tab spacing 2025-11-26 18:42:34 +02:00
Clarity-89
25fbd3b0f3 Fix link 2025-11-26 18:41:00 +02:00
Clarity-89
c73497b5a6 Fix status 2025-11-26 18:26:25 +02:00
Clarity-89
2f3628c2cd Omit root 2025-11-26 18:03:17 +02:00
Clarity-89
868762df39 Show status 2025-11-26 18:02:23 +02:00
Clarity-89
4ba780de4a Add tests 2025-11-26 17:37:00 +02:00
Clarity-89
5808a4d7f5 Use interactive table 2025-11-26 16:01:01 +02:00
Clarity-89
2156782b4c Provisioning: Unify resources and files view 2025-11-26 14:08:17 +02:00
10 changed files with 1290 additions and 480 deletions

View File

@@ -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<typeof useGetRepositoryFilesQuery>;
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<RepositoryFilesQueryResult> = {}) => {
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(<FilesView repo={repo} />);
};
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();
});
});

View File

@@ -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<T extends keyof FileDetails = keyof FileDetails> = CellProps<FileDetails, FileDetails[T]>;
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<Column<FileDetails>> = [
{
id: 'path',
header: 'Path',
sortType: 'string',
cell: ({ row: { original } }: FileCell<'path'>) => {
const { path } = original;
const isDotKeepFile = getIsDotKeepFile(path);
if (isDotKeepFile) {
return path;
}
return <a href={`${PROVISIONING_URL}/${name}/file/${path}`}>{path}</a>;
},
},
{
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 (
<Stack>
{(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && (
<LinkButton href={`${PROVISIONING_URL}/${name}/file/${path}`}>
<Trans i18nKey="provisioning.files-view.columns.view">View</Trans>
</LinkButton>
)}
{showHistoryBtn && (
<LinkButton href={`${PROVISIONING_URL}/${name}/history/${path}?repo_type=${repo.spec?.type}`}>
<Trans i18nKey="provisioning.files-view.columns.history">History</Trans>
</LinkButton>
)}
</Stack>
);
},
},
];
if (query.isLoading) {
return (
<Stack justifyContent={'center'} alignItems={'center'}>
<Spinner />
</Stack>
);
}
return (
<Stack grow={1} direction={'column'} gap={2}>
<Stack gap={2}>
<FilterInput
placeholder={t('provisioning.files-view.placeholder-search', 'Search')}
autoFocus={true}
value={searchQuery}
onChange={setSearchQuery}
/>
</Stack>
<InteractiveTable columns={columns} data={data} pageSize={25} getRowId={(f: FileDetails) => String(f.path)} />
</Stack>
);
}
function getIsDotKeepFile(path: string): boolean {
// e.g. 'dashboards/.keep' → true, 'dashboards/example.keep.json' → false
return path.split('/').pop() === '.keep';
}

View File

@@ -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<T extends keyof ResourceListItem = keyof ResourceListItem> = 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<Column<ResourceListItem>> = useMemo(
() => [
{
id: 'title',
header: 'Title',
sortType: 'string',
cell: ({ row: { original } }: ResourceCell<'title'>) => {
const { resource, name, title } = original;
if (resource === 'dashboards') {
return <a href={`/d/${name}`}>{title}</a>;
}
if (resource === 'folders') {
return <a href={`/dashboards/f/${name}`}>{title}</a>;
}
return <span>{title}</span>;
},
},
{
id: 'resource',
header: 'Type',
sortType: 'string',
cell: ({ row: { original } }: ResourceCell<'resource'>) => {
return <span style={{ textTransform: 'capitalize' }}>{original.resource}</span>;
},
},
{
id: 'path',
header: 'Path',
sortType: 'string',
cell: ({ row: { original } }: ResourceCell<'path'>) => {
const { resource, name, path } = original;
if (resource === 'dashboards') {
return <a href={`/d/${name}`}>{path}</a>;
}
return <span>{path}</span>;
},
},
{
id: 'hash',
header: 'Hash',
sortType: 'string',
cell: ({ row: { original } }: ResourceCell<'hash'>) => {
const { hash } = original;
return <span title={hash}>{hash.substring(0, 7)}</span>;
},
},
{
id: 'folder',
header: 'Folder',
sortType: 'string',
cell: ({ row: { original } }: ResourceCell<'title'>) => {
const { folder } = original;
if (folder?.length) {
return <Link href={`/dashboards/f/${folder}`}>{folder}</Link>;
}
return <span></span>;
},
},
{
id: 'actions',
header: '',
cell: ({ row: { original } }: ResourceCell) => {
const { resource, name, path } = original;
return (
<Stack>
{resource === 'dashboards' && (
<LinkButton href={`/d/${name}`}>
<Trans i18nKey="provisioning.repository-resources.columns.view-dashboard">View</Trans>
</LinkButton>
)}
{resource === 'folders' && (
<LinkButton href={`/dashboards/f/${name}`}>
<Trans i18nKey="provisioning.repository-resources.columns.view-folder">View</Trans>
</LinkButton>
)}
{historySupported && (
<LinkButton
href={`${PROVISIONING_URL}/${repo.metadata?.name}/history/${path}?repo_type=${repo.spec?.type}`}
>
<Trans i18nKey="provisioning.repository-resources.columns.history">History</Trans>
</LinkButton>
)}
</Stack>
);
},
},
],
[repo.metadata?.name, historySupported, repo.spec?.type]
);
if (query.isLoading) {
return (
<Stack justifyContent={'center'} alignItems={'center'}>
<Spinner />
</Stack>
);
}
return (
<Stack grow={1} direction={'column'} gap={2}>
<Stack gap={2}>
<FilterInput
placeholder={t('provisioning.repository-resources.placeholder-search', 'Search')}
autoFocus={true}
value={searchQuery}
onChange={setSearchQuery}
/>
</Stack>
<InteractiveTable
columns={columns}
data={data}
pageSize={25}
getRowId={(r: ResourceListItem) => String(r.path)}
/>
</Stack>
);
}

View File

@@ -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 ? (
<>
<Stack gap={2} direction="column">
<TabsBar>
{tabInfo.map((t: SelectableValue) => (
<Tab
@@ -124,10 +117,9 @@ export default function RepositoryStatusPage() {
</Alert>
)}
{tab === TabSelection.Overview && <RepositoryOverview repo={data} />}
{tab === TabSelection.Resources && <RepositoryResources repo={data} />}
{tab === TabSelection.Files && <FilesView repo={data} />}
{tab === TabSelection.Resources && <ResourceTreeView repo={data} />}
</TabContent>
</>
</Stack>
) : (
<div>
<Trans i18nKey="provisioning.repository-status-page.not-found">not found</Trans>

View File

@@ -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<T extends keyof FlatTreeItem = keyof FlatTreeItem> = CellProps<FlatTreeItem, FlatTreeItem[T]>;
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<Column<FlatTreeItem>> = 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 (
<div className={styles.titleCell} style={{ paddingLeft: level * 24 }}>
<Icon name={iconName} className={styles.icon} />
{link ? <Link href={link}>{item.title}</Link> : <span>{item.title}</span>}
</div>
);
},
},
{
id: 'type',
header: t('provisioning.resource-tree.header-type', 'Type'),
cell: ({ row: { original } }: TreeCell) => {
return <span>{original.item.type}</span>;
},
},
{
id: 'status',
header: t('provisioning.resource-tree.header-status', 'Status'),
cell: ({ row: { original } }: TreeCell) => {
const { status } = original.item;
if (!status) {
return null;
}
return (
<Icon
name={status === 'synced' ? 'check-circle' : 'sync'}
className={status === 'synced' ? styles.syncedIcon : undefined}
title={
status === 'synced'
? t('provisioning.resource-tree.status-synced', 'Synced')
: t('provisioning.resource-tree.status-pending', 'Pending')
}
/>
);
},
},
{
id: 'hash',
header: t('provisioning.resource-tree.header-hash', 'Hash'),
cell: ({ row: { original } }: TreeCell) => {
const { hash } = original.item;
if (!hash) {
return null;
}
return (
<span title={hash} className={styles.hash}>
{hash.substring(0, 7)}
</span>
);
},
},
{
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 (
<Stack direction="row" gap={1}>
{viewLink && (
<LinkButton href={viewLink} size="sm" variant="secondary">
<Trans i18nKey="provisioning.resource-tree.view">View</Trans>
</LinkButton>
)}
{sourceLink && (
<LinkButton href={sourceLink} size="sm" variant="secondary" target="_blank">
<Trans i18nKey="provisioning.resource-tree.source">Source</Trans>
</LinkButton>
)}
</Stack>
);
},
},
],
[repo.spec, styles]
);
if (isLoading) {
return (
<Stack justifyContent="center" alignItems="center">
<Spinner />
</Stack>
);
}
return (
<Stack direction="column" gap={2}>
<FilterInput
placeholder={t('provisioning.resource-tree.search-placeholder', 'Search by path or title')}
autoFocus={true}
value={searchQuery}
onChange={setSearchQuery}
/>
<InteractiveTable
columns={columns}
data={flatItems}
pageSize={25}
getRowId={(item: FlatTreeItem) => item.item.path}
/>
</Stack>
);
}
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,
}),
});

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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');
});
});

View File

@@ -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<string, MergedItem>();
const inferredFolders = new Set<string>();
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<string, TreeItem>();
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);
}

View File

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