Provisioning: Add README as a dedicated tab for provisioned folders
Instead of showing README inline on the Dashboards tab, this adds a dedicated README tab as the first tab for Git Sync provisioned folders. Changes: - Add getReadmeTabID and README tab to folder nav model - Create BrowseFolderReadmePage component - Add /dashboards/f/:uid/:slug/readme route - Update FolderReadmeContent to show appropriate empty states - Remove inline FolderReadme from BrowseDashboardsPage
This commit is contained in:
@@ -17,7 +17,6 @@ import { contextSrv } from '../../core/services/context_srv';
|
||||
import { ManagerKind } from '../apiserver/types';
|
||||
import { TemplateDashboardModal } from '../dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal';
|
||||
import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel';
|
||||
import { FolderReadme } from '../provisioning/components/Folders/FolderReadme';
|
||||
import { ProvisionedFolderPreviewBanner } from '../provisioning/components/Folders/ProvisionedFolderPreviewBanner';
|
||||
import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView';
|
||||
import { useSearchStateManager } from '../search/state/SearchStateManager';
|
||||
@@ -180,7 +179,6 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
>
|
||||
<Page.Contents className={styles.pageContents}>
|
||||
<ProvisionedFolderPreviewBanner queryParams={queryParams} />
|
||||
{folderUID && <FolderReadme folderUID={folderUID} />}
|
||||
{/* only show recently viewed dashboards when in root */}
|
||||
{!folderUID && <RecentlyViewedDashboards />}
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
|
||||
import { ManagerKind } from '../apiserver/types';
|
||||
import { FolderActionsButton } from '../browse-dashboards/components/FolderActionsButton';
|
||||
import { buildNavModel, getReadmeTabID } from '../folders/state/navModel';
|
||||
import { FolderReadmeContent } from '../provisioning/components/Folders/FolderReadmeContent';
|
||||
import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView';
|
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
export function BrowseFolderReadmePage() {
|
||||
const { uid: folderUID = '' } = useParams();
|
||||
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
|
||||
const [saveFolder] = useUpdateFolder();
|
||||
const { repoType, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUID });
|
||||
|
||||
const navModel = useMemo(() => {
|
||||
if (!folderDTO) {
|
||||
return undefined;
|
||||
}
|
||||
const model = buildNavModel(folderDTO);
|
||||
|
||||
// Set the "README" tab to active
|
||||
const readmeTabID = getReadmeTabID(folderDTO.uid);
|
||||
const readmeTab = model.children?.find((child) => child.id === readmeTabID);
|
||||
if (readmeTab) {
|
||||
readmeTab.active = true;
|
||||
}
|
||||
return model;
|
||||
}, [folderDTO]);
|
||||
|
||||
const isProvisionedFolder = folderDTO?.managedBy === ManagerKind.Repo;
|
||||
|
||||
const onEditTitle =
|
||||
folderUID && !isProvisionedFolder
|
||||
? async (newValue: string) => {
|
||||
if (folderDTO) {
|
||||
const result = await saveFolder({
|
||||
...folderDTO,
|
||||
title: newValue,
|
||||
});
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Page
|
||||
navId="dashboards/browse"
|
||||
pageNav={navModel}
|
||||
onEditTitle={onEditTitle}
|
||||
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} repoType={repoType} isReadOnlyRepo={isReadOnlyRepo} />}</>}
|
||||
>
|
||||
<Page.Contents>
|
||||
<FolderReadmeContent folderUID={folderUID} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrowseFolderReadmePage;
|
||||
@@ -9,6 +9,7 @@ import { FolderDTO, FolderParent } from 'app/types/folders';
|
||||
|
||||
export const FOLDER_ID = 'manage-folder';
|
||||
|
||||
export const getReadmeTabID = (folderUID: string) => `folder-readme-${folderUID}`;
|
||||
export const getDashboardsTabID = (folderUID: string) => `folder-dashboards-${folderUID}`;
|
||||
export const getLibraryPanelsTabID = (folderUID: string) => `folder-library-panels-${folderUID}`;
|
||||
export const getAlertingTabID = (folderUID: string) => `folder-alerting-${folderUID}`;
|
||||
@@ -19,21 +20,35 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol
|
||||
const parents = parentsArg ?? ('parents' in folder ? folder.parents : undefined);
|
||||
const isProvisioned = 'managedBy' in folder ? folder.managedBy === ManagerKind.Repo : false;
|
||||
|
||||
const children: NavModelItem[] = [];
|
||||
|
||||
// Add README tab first for provisioned folders
|
||||
if (isProvisioned && config.featureToggles.provisioning) {
|
||||
children.push({
|
||||
active: false,
|
||||
icon: 'document-info',
|
||||
id: getReadmeTabID(folder.uid),
|
||||
text: t('browse-dashboards.manage-folder-nav.readme', 'README'),
|
||||
url: `${folder.url}/readme`,
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboards tab
|
||||
children.push({
|
||||
active: false,
|
||||
icon: 'apps',
|
||||
id: getDashboardsTabID(folder.uid),
|
||||
text: t('browse-dashboards.manage-folder-nav.dashboards', 'Dashboards'),
|
||||
url: folder.url,
|
||||
});
|
||||
|
||||
const model: NavModelItem = {
|
||||
icon: 'folder',
|
||||
id: FOLDER_ID,
|
||||
subTitle: getNavSubTitle('manage-folder'),
|
||||
url: folder.url,
|
||||
text: folder.title,
|
||||
children: [
|
||||
{
|
||||
active: false,
|
||||
icon: 'apps',
|
||||
id: getDashboardsTabID(folder.uid),
|
||||
text: t('browse-dashboards.manage-folder-nav.dashboards', 'Dashboards'),
|
||||
url: folder.url,
|
||||
},
|
||||
],
|
||||
children,
|
||||
};
|
||||
|
||||
if (parents && parents.length > 0) {
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { useGetRepositoryFilesWithPathQuery, RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView';
|
||||
|
||||
import { FolderReadme } from './FolderReadme';
|
||||
import { FolderReadmeContent } from './FolderReadme';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
config: {
|
||||
featureToggles: {
|
||||
provisioning: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
|
||||
useGetRepositoryFilesWithPathQuery: jest.fn(),
|
||||
}));
|
||||
@@ -52,31 +42,13 @@ const mockFolder = {
|
||||
status: {},
|
||||
};
|
||||
|
||||
describe('FolderReadme', () => {
|
||||
describe('FolderReadmeContent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(config.featureToggles as Record<string, boolean>).provisioning = true;
|
||||
});
|
||||
|
||||
describe('when provisioning is disabled', () => {
|
||||
it('should not render anything', () => {
|
||||
(config.featureToggles as Record<string, boolean>).provisioning = false;
|
||||
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
const { container } = render(<FolderReadme folderUID="test-folder" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when folder is not provisioned', () => {
|
||||
it('should not render anything when repository is undefined', () => {
|
||||
it('should show not provisioned message when repository is undefined', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: undefined,
|
||||
folder: undefined,
|
||||
@@ -85,26 +57,21 @@ describe('FolderReadme', () => {
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
const { container } = render(<FolderReadme folderUID="test-folder" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render anything when folderUID is undefined', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(<FolderReadme folderUID={undefined} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/not managed by a Git repository/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loading', () => {
|
||||
it('should not render anything while loading repository info', () => {
|
||||
it('should show loading spinner while loading repository info', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
@@ -113,8 +80,8 @@ describe('FolderReadme', () => {
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
const { container } = render(<FolderReadme folderUID="test-folder" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading spinner while fetching README', () => {
|
||||
@@ -134,14 +101,13 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadme folderUID="test-folder" />);
|
||||
// Check for spinner by looking for the role or a common class
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when README fetch fails', () => {
|
||||
it('should not render anything on error', () => {
|
||||
it('should show not found message on error', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
@@ -158,11 +124,11 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(<FolderReadme folderUID="test-folder" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/No README.md file found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render anything when file data is empty', () => {
|
||||
it('should show not found message when file data is empty', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
@@ -179,8 +145,8 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(<FolderReadme folderUID="test-folder" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/No README.md file found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,7 +174,7 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadme folderUID="test-folder" />);
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
|
||||
// The markdown should be rendered as HTML
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
@@ -236,11 +202,11 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadme folderUID="test-folder" />);
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText('Direct String Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when file content cannot be extracted', () => {
|
||||
it('should show parse error when file content cannot be extracted', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
@@ -261,8 +227,8 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(<FolderReadme folderUID="test-folder" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/Unable to display README content/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,7 +250,7 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadme folderUID="test-folder" />);
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
|
||||
// Verify the query was called with the correct path
|
||||
expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({
|
||||
@@ -319,7 +285,7 @@ describe('FolderReadme', () => {
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadme folderUID="test-folder" />);
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
|
||||
expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({
|
||||
name: 'test-repo',
|
||||
|
||||
@@ -2,27 +2,24 @@ import { css } from '@emotion/css';
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Card, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { Box, Spinner, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { useGetRepositoryFilesWithPathQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||
|
||||
import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView';
|
||||
|
||||
interface FolderReadmeProps {
|
||||
folderUID: string | undefined;
|
||||
interface FolderReadmeContentProps {
|
||||
folderUID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FolderReadme fetches and renders a README.md file from a Git Sync provisioned folder.
|
||||
* It only renders when:
|
||||
* - The provisioning feature is enabled
|
||||
* - The folder is managed by a repository
|
||||
* - The README.md file exists and can be fetched
|
||||
* FolderReadmeContent fetches and renders a README.md file from a Git Sync provisioned folder.
|
||||
* This is the main content component used in the README tab.
|
||||
*/
|
||||
export function FolderReadme({ folderUID }: FolderReadmeProps) {
|
||||
export function FolderReadmeContent({ folderUID }: FolderReadmeContentProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const provisioningEnabled = config.featureToggles.provisioning;
|
||||
|
||||
// Get repository info for the folder
|
||||
const { repository, folder, isLoading: isRepoLoading } = useGetResourceRepositoryView({
|
||||
@@ -34,7 +31,7 @@ export function FolderReadme({ folderUID }: FolderReadmeProps) {
|
||||
const readmePath = sourcePath ? `${sourcePath}/README.md` : 'README.md';
|
||||
|
||||
// Determine if we should fetch the README
|
||||
const shouldFetch = provisioningEnabled && !!repository && !!folderUID && !isRepoLoading;
|
||||
const shouldFetch = !!repository && !!folderUID && !isRepoLoading;
|
||||
|
||||
// Fetch the README.md file from the repository
|
||||
const {
|
||||
@@ -50,82 +47,101 @@ export function FolderReadme({ folderUID }: FolderReadmeProps) {
|
||||
: skipToken
|
||||
);
|
||||
|
||||
// Don't render if provisioning is disabled or folder is not managed
|
||||
if (!provisioningEnabled || !folderUID || !repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading spinner while fetching repository info
|
||||
if (isRepoLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading spinner while fetching README
|
||||
if (isFileLoading) {
|
||||
// Show loading spinner while fetching repository info or README
|
||||
if (isRepoLoading || isFileLoading) {
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
</Card>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" paddingY={4}>
|
||||
<Spinner size="lg" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if there was an error (README doesn't exist or unsupported)
|
||||
// Show empty state if folder is not managed by a repository
|
||||
if (!repository) {
|
||||
return (
|
||||
<Box paddingY={4}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="browse-dashboards.readme.not-provisioned">
|
||||
This folder is not managed by a Git repository.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state if there was an error or no README exists
|
||||
if (isError || !fileData) {
|
||||
return null;
|
||||
return (
|
||||
<Box paddingY={4}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="browse-dashboards.readme.not-found">
|
||||
No README.md file found in this folder.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
<Trans i18nKey="browse-dashboards.readme.add-hint">
|
||||
Add a README.md file to your repository to display documentation here.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the raw content from the file data
|
||||
// The API returns a ResourceWrapper with resource.file containing the file data
|
||||
const fileContent = fileData.resource?.file;
|
||||
if (!fileContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For markdown files, the content might be in different formats depending on how the API returns it
|
||||
// Try to get the content as a string
|
||||
let markdownContent: string | undefined;
|
||||
|
||||
if (typeof fileContent === 'string') {
|
||||
markdownContent = fileContent;
|
||||
} else if (typeof fileContent === 'object') {
|
||||
// If it's an object, try common property names
|
||||
markdownContent =
|
||||
(fileContent as Record<string, unknown>).content as string | undefined ||
|
||||
(fileContent as Record<string, unknown>).data as string | undefined ||
|
||||
(fileContent as Record<string, unknown>).spec as string | undefined;
|
||||
|
||||
// If still not found, try to get raw text if available
|
||||
if (!markdownContent && (fileContent as Record<string, unknown>).raw) {
|
||||
markdownContent = (fileContent as Record<string, unknown>).raw as string;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't render if we couldn't extract the content
|
||||
if (!markdownContent || typeof markdownContent !== 'string') {
|
||||
return null;
|
||||
return (
|
||||
<Box paddingY={4}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="browse-dashboards.readme.parse-error">
|
||||
Unable to display README content.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the markdown content
|
||||
const renderedHtml = renderMarkdown(markdownContent);
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.container}>
|
||||
<div className="markdown-html" dangerouslySetInnerHTML={{ __html: renderedHtml }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
card: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
loadingContainer: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
container: css({
|
||||
padding: theme.spacing(2),
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
});
|
||||
|
||||
// Keep the old export for backwards compatibility during transition
|
||||
export { FolderReadmeContent as FolderReadme };
|
||||
|
||||
@@ -484,6 +484,15 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "TestStuffPage"*/ 'app/features/sandbox/TestStuffPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/dashboards/f/:uid/:slug/readme',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "FolderReadmePage"*/ 'app/features/browse-dashboards/BrowseFolderReadmePage'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/dashboards/f/:uid/:slug/library-panels',
|
||||
component: SafeDynamicImport(
|
||||
|
||||
Reference in New Issue
Block a user