mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
23 Commits
KD/lazy-lo
...
provisioni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a91abefa3 | ||
|
|
521c32a0dd | ||
|
|
a0bef8fe60 | ||
|
|
1455218569 | ||
|
|
db29ea05d3 | ||
|
|
89b7fa914a | ||
|
|
fa6ecff014 | ||
|
|
995cf2624e | ||
|
|
208807ab88 | ||
|
|
46586bc1a0 | ||
|
|
ef9ce9f072 | ||
|
|
0d095c3b7c | ||
|
|
c4693b9eb7 | ||
|
|
f8f6f70678 | ||
|
|
0775009f86 | ||
|
|
f402c82d1c | ||
|
|
25fbd3b0f3 | ||
|
|
c73497b5a6 | ||
|
|
2f3628c2cd | ||
|
|
868762df39 | ||
|
|
4ba780de4a | ||
|
|
5808a4d7f5 | ||
|
|
2156782b4c |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
213
public/app/features/provisioning/Repository/ResourceTreeView.tsx
Normal file
213
public/app/features/provisioning/Repository/ResourceTreeView.tsx
Normal 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,
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
715
public/app/features/provisioning/utils/treeUtils.test.ts
Normal file
715
public/app/features/provisioning/utils/treeUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
229
public/app/features/provisioning/utils/treeUtils.ts
Normal file
229
public/app/features/provisioning/utils/treeUtils.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user