[Provisioning] Display instance connection directly in home (#101720)

* Show tabs directly in home page for instance sync

* Display actions also in the home page

* Add delete button to actions

* Fix issue with files tab

* Display tabs also in instant sync

* Generate connection name for instance sync

* Fix issues when no repositories

* Set active tab

* Remove leftover

* Rename component for listing folder repository

* Fix linting issues

* Fix merge error

* Rename to HomePage

* Split folder list into separate component

* Create own component for repository card

* Improve RepositoryCard readability

* Improve RepositoryCard styling

* Make view primary button

* Fix syntax

* Fix generateName
This commit is contained in:
Roberto Jiménez Sánchez authored and GitHub committed 2025-03-10 13:58:24 +01:00
1 parent c6a4ca4d41
commit bf591b2947
11 files changed
+569 -422

No files matched your search

+34 -26
View File
@@ -5697,6 +5697,18 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
],
"public/app/features/provisioning/FilesView.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/provisioning/FolderRepositoryList.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
@@ -5732,6 +5744,14 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/provisioning/HomePage.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/provisioning/JobStatus.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
@@ -5759,26 +5779,23 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/provisioning/RepositoryActions.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/provisioning/RepositoryCard.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/provisioning/RepositoryHealth.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/provisioning/RepositoryListPage.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
],
"public/app/features/provisioning/RepositoryOverview.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
@@ -5811,19 +5828,10 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "15"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"]
],
"public/app/features/provisioning/SyncRepository.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@@ -0,0 +1,112 @@
import { useState } from 'react';
import {
Button,
CellProps,
Column,
ConfirmModal,
FilterInput,
InteractiveTable,
LinkButton,
Spinner,
Stack,
} from '@grafana/ui';
import { Repository, useGetRepositoryFilesQuery, useDeleteRepositoryFilesWithPathMutation } from './api';
import { FileDetails } from './api/types';
import { PROVISIONING_URL } from './constants';
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 [deleteFile, deleteFileStatus] = useDeleteRepositoryFilesWithPathMutation();
const [searchQuery, setSearchQuery] = useState('');
const [pathToDelete, setPathToDelete] = useState<string>();
const data = [...(query.data?.items ?? [])].filter((file) =>
file.path.toLowerCase().includes(searchQuery.toLowerCase())
);
const columns: Array<Column<FileDetails>> = [
{
id: 'path',
header: 'Path',
sortType: 'string',
cell: ({ row: { original } }: FileCell<'path'>) => {
const { path } = original;
return <a href={`${PROVISIONING_URL}/${name}/file/${path}`}>{path}</a>;
},
},
{
id: 'size',
header: 'Size (KB)',
cell: ({ row: { original } }: FileCell<'size'>) => {
const { size } = original;
return (parseInt(size, 10) / 1024).toFixed(2);
},
sortType: 'number',
},
{
id: 'hash',
header: 'Hash',
sortType: 'string',
},
{
id: 'actions',
header: '',
cell: ({ row: { original } }: FileCell<'path'>) => {
const { path } = original;
return (
<Stack>
{(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && (
<LinkButton href={`${PROVISIONING_URL}/${name}/file/${path}`}>View</LinkButton>
)}
<LinkButton href={`${PROVISIONING_URL}/${name}/history/${path}`}>History</LinkButton>
<Button variant="destructive" onClick={() => setPathToDelete(path)}>
Delete
</Button>
</Stack>
);
},
},
];
if (query.isLoading) {
return (
<Stack justifyContent={'center'} alignItems={'center'}>
<Spinner />
</Stack>
);
}
return (
<Stack grow={1} direction={'column'} gap={2}>
<ConfirmModal
isOpen={Boolean(pathToDelete?.length) || deleteFileStatus.isLoading}
title="Delete file in repository?"
body={deleteFileStatus.isLoading ? 'Deleting file...' : pathToDelete}
confirmText="Delete"
icon={deleteFileStatus.isLoading ? `spinner` : `exclamation-triangle`}
onConfirm={() => {
deleteFile({
name: name,
path: pathToDelete!,
message: `Deleted from repo test UI`,
});
setPathToDelete('');
}}
onDismiss={() => setPathToDelete('')}
/>
<Stack gap={2}>
<FilterInput placeholder="Search" autoFocus={true} value={searchQuery} onChange={setSearchQuery} />
</Stack>
<InteractiveTable columns={columns} data={data} pageSize={25} getRowId={(f: FileDetails) => String(f.path)} />
</Stack>
);
}
@@ -0,0 +1,35 @@
import { useState } from 'react';
import { EmptySearchResult, FilterInput, LinkButton, Stack } from '@grafana/ui';
import { RepositoryCard } from './RepositoryCard';
import { Repository, useGetFrontendSettingsQuery } from './api';
import { CONNECT_URL } from './constants';
import { checkSyncSettings } from './utils';
export function FolderRepositoryList({ items }: { items: Repository[] }) {
const [query, setQuery] = useState('');
const filteredItems = items.filter((item) => item.metadata?.name?.includes(query));
const settings = useGetFrontendSettingsQuery();
const [instanceConnected] = checkSyncSettings(settings.data);
return (
<Stack direction={'column'} gap={3}>
<Stack gap={2}>
<FilterInput placeholder="Search" value={query} onChange={setQuery} />
{!instanceConnected && (
<LinkButton href={CONNECT_URL} variant="primary" icon={'plus'}>
Connect to repository
</LinkButton>
)}
</Stack>
<Stack direction={'column'}>
{!!filteredItems.length ? (
filteredItems.map((item) => <RepositoryCard key={item.metadata?.name} repository={item} />)
) : (
<EmptySearchResult>No results matching your query </EmptySearchResult>
)}
</Stack>
</Stack>
);
}
@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import { AppEvents } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { Alert, ConfirmModal, Modal, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ExportToRepository } from './ExportToRepository';
import { FilesView } from './FilesView';
import { FolderRepositoryList } from './FolderRepositoryList';
import GettingStarted from './GettingStarted/GettingStarted';
import GettingStartedPage from './GettingStarted/GettingStartedPage';
import { MigrateToRepository } from './MigrateToRepository';
import { RepositoryActions } from './RepositoryActions';
import { RepositoryOverview } from './RepositoryOverview';
import { RepositoryResources } from './RepositoryResources';
import { useDeletecollectionRepositoryMutation, useGetFrontendSettingsQuery } from './api';
import { useRepositoryList } from './hooks';
import { checkSyncSettings } from './utils';
const appEvents = getAppEvents();
enum TabSelection {
Overview = 'overview',
Resources = 'resources',
Files = 'files',
GettingStarted = 'getting-started',
Repositories = 'repositories',
}
const connectedTabInfo = [
{ value: TabSelection.Overview, label: 'Overview', title: 'Repository overview' },
{ value: TabSelection.Resources, label: 'Resources', title: 'Resources saved in Grafana database' },
{ value: TabSelection.Files, label: 'Files', title: 'The raw file list from the repository' },
{ value: TabSelection.GettingStarted, label: 'Getting started', title: 'Getting started' },
];
const disconnectedTabInfo = [
{ value: TabSelection.Repositories, label: 'Repositories', title: 'List of repositories' },
{ value: TabSelection.GettingStarted, label: 'Getting started', title: 'Getting started' },
];
export default function HomePage() {
const [items, isLoading] = useRepositoryList({ watch: true });
const settings = useGetFrontendSettingsQuery();
const [deleteAll, deleteAllResult] = useDeletecollectionRepositoryMutation();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showMigrateModal, setShowMigrateModal] = useState(false);
const [instanceConnected] = checkSyncSettings(settings.data);
const [activeTab, setActiveTab] = useState<TabSelection>(
instanceConnected ? TabSelection.Overview : TabSelection.Repositories
);
useEffect(() => {
if (deleteAllResult.isSuccess) {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: ['All configured repositories deleted'],
});
}
}, [deleteAllResult.isSuccess]);
useEffect(() => {
setActiveTab(instanceConnected ? TabSelection.Overview : TabSelection.Repositories);
}, [instanceConnected]);
// Early return for onboarding
if (!items?.length && !isLoading) {
return <GettingStartedPage />;
}
const onConfirmDelete = () => {
deleteAll({});
setShowDeleteModal(false);
};
const renderTabContent = () => {
if (!instanceConnected) {
switch (activeTab) {
case TabSelection.Repositories:
return <FolderRepositoryList items={items ?? []} />;
case TabSelection.GettingStarted:
return <GettingStarted />;
default:
return null;
}
}
const repo = items?.[0];
if (!repo) {
return null;
}
switch (activeTab) {
case TabSelection.Overview:
return <RepositoryOverview repo={repo} />;
case TabSelection.Resources:
return <RepositoryResources repo={repo} />;
case TabSelection.Files:
return <FilesView repo={repo} />;
case TabSelection.GettingStarted:
return <GettingStarted />;
default:
return null;
}
};
return (
<Page
navId="provisioning"
subTitle="View and manage your configured repositories"
actions={
instanceConnected && items?.length ? (
<RepositoryActions
repository={items[0]}
showMigrateButton={settings.data?.legacyStorage}
onExportClick={() => setShowExportModal(true)}
onMigrateClick={() => setShowMigrateModal(true)}
/>
) : undefined
}
>
<Page.Contents isLoading={isLoading}>
{settings.data?.legacyStorage && (
<Alert
title="Legacy storage detected"
severity="error"
buttonContent={<>Remove all configured repositories</>}
onRemove={() => {
setShowDeleteModal(true);
}}
>
Configured repositories will not work while running legacy storage.
</Alert>
)}
<ConfirmModal
isOpen={showDeleteModal}
title="Delete all configured repositories"
body="Are you sure you want to delete all configured repositories? This action cannot be undone."
confirmText="Delete repositories"
onConfirm={onConfirmDelete}
onDismiss={() => setShowDeleteModal(false)}
/>
{showExportModal && items?.length && (
<Modal isOpen={true} title="Export to Repository" onDismiss={() => setShowExportModal(false)}>
<ExportToRepository repo={items[0]} />
</Modal>
)}
{showMigrateModal && items?.length && (
<Modal isOpen={true} title="Migrate to Repository" onDismiss={() => setShowMigrateModal(false)}>
<MigrateToRepository repo={items[0]} />
</Modal>
)}
<Stack direction="column" gap={2}>
<TabsBar>
{(instanceConnected ? connectedTabInfo : disconnectedTabInfo).map((t) => (
<Tab
key={t.value}
label={t.label}
active={activeTab === t.value}
onChangeTab={() => setActiveTab(t.value)}
title={t.title}
/>
))}
</TabsBar>
<TabContent>{renderTabContent()}</TabContent>
</Stack>
</Page.Contents>
</Page>
);
}
@@ -0,0 +1,54 @@
import { Button, LinkButton, Stack } from '@grafana/ui';
import { DeleteRepositoryButton } from './DeleteRepositoryButton';
import { StatusBadge } from './StatusBadge';
import { SyncRepository } from './SyncRepository';
import { Repository } from './api';
import { PROVISIONING_URL } from './constants';
import { getRemoteURL } from './utils/git';
interface RepositoryActionsProps {
repository: Repository;
showMigrateButton?: boolean;
onExportClick?: () => void;
onMigrateClick?: () => void;
}
export function RepositoryActions({
repository,
showMigrateButton,
onExportClick,
onMigrateClick,
}: RepositoryActionsProps) {
const remoteURL = getRemoteURL(repository);
const name = repository.metadata?.name ?? '';
return (
<Stack>
<StatusBadge
enabled={Boolean(repository.spec?.sync?.enabled)}
state={repository.status?.sync?.state}
name={name}
/>
{remoteURL && (
<Button variant="secondary" icon="github" onClick={() => window.open(remoteURL, '_blank')}>
Source Code
</Button>
)}
<SyncRepository repository={repository} />
{showMigrateButton ? (
<Button variant="secondary" icon="cloud-upload" onClick={onMigrateClick}>
Migrate
</Button>
) : (
<Button variant="secondary" icon="cloud-upload" onClick={onExportClick}>
Push
</Button>
)}
<LinkButton variant="secondary" icon="cog" href={`${PROVISIONING_URL}/${name}/edit`}>
Settings
</LinkButton>
<DeleteRepositoryButton name={name} />
</Stack>
);
}
@@ -0,0 +1,124 @@
import { ReactNode } from 'react';
import { IconName, Stack, Text, TextLink, Icon, Card, LinkButton } from '@grafana/ui';
import { DeleteRepositoryButton } from './DeleteRepositoryButton';
import { StatusBadge } from './StatusBadge';
import { SyncRepository } from './SyncRepository';
import { Repository, ResourceCount } from './api';
import { PROVISIONING_URL } from './constants';
interface Props {
repository: Repository;
}
export function RepositoryCard({ repository }: Props) {
const { metadata, spec, status } = repository;
const name = metadata?.name ?? '';
const getRepositoryMeta = (): ReactNode[] => {
const meta: ReactNode[] = [];
if (spec?.type === 'github') {
const { url = '', branch } = spec.github ?? {};
const branchUrl = branch ? `${url}/tree/${branch}` : url;
meta.push(
<TextLink key="link" external href={branchUrl}>
{branchUrl}
</TextLink>
);
if (status?.webhook?.id) {
const webhookUrl = `${url}/settings/hooks/${status.webhook.id}`;
meta.push(
<Stack gap={1} direction="row" alignItems="center">
<TextLink key="webhook" href={webhookUrl}>
Webhook
</TextLink>
<Icon name="check" className="text-success" />
</Stack>
);
}
} else if (spec?.type === 'local') {
meta.push(
<Text variant="bodySmall" key="path">
{spec.local?.path ?? ''}
</Text>
);
}
return meta;
};
const getRepositoryIcon = (): IconName => {
return spec?.type === 'github' ? 'github' : 'database';
};
return (
<Card key={name}>
<Card.Figure>
<Icon name={getRepositoryIcon()} size="xxl" />
</Card.Figure>
<Card.Heading>
<Stack gap={2} direction="row" alignItems="center">
{spec?.title && <Text variant="h3">{spec.title}</Text>}
<StatusBadge enabled={Boolean(spec?.sync?.enabled)} state={status?.sync?.state} name={name} />
</Stack>
</Card.Heading>
<Card.Description>
<Stack gap={2} direction="column">
{spec?.description && <Text>{spec.description}</Text>}
{status?.stats?.length && (
<Stack gap={1} direction="row" wrap>
{status.stats.map((stat, index) => (
<LinkButton
key={index}
fill="outline"
size="md"
variant="secondary"
href={getListURL(repository, stat)}
>
{stat.count} {stat.resource}
</LinkButton>
))}
</Stack>
)}
</Stack>
</Card.Description>
<Card.Meta>
<Stack gap={2} direction="row" wrap>
{getRepositoryMeta()}
</Stack>
</Card.Meta>
<Card.Actions>
<Stack gap={1} direction="row">
<LinkButton icon="eye" href={`${PROVISIONING_URL}/${name}`} variant="primary" size="md">
View
</LinkButton>
<SyncRepository repository={repository} />
<LinkButton variant="secondary" icon="cog" href={`${PROVISIONING_URL}/${name}/edit`} size="md">
Settings
</LinkButton>
</Stack>
</Card.Actions>
<Card.SecondaryActions>
<DeleteRepositoryButton name={name} />
</Card.SecondaryActions>
</Card>
);
}
// Helper function
function getListURL(repo: Repository, stats: ResourceCount): string {
if (stats.resource === 'playlists') {
return '/playlists';
}
if (repo.spec?.sync.target === 'folder') {
return `/dashboards/f/${repo.metadata?.name}`;
}
return '/dashboards';
}
@@ -1,232 +0,0 @@
import { ReactNode, useEffect, useState } from 'react';
import { AppEvents } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import {
Card,
EmptySearchResult,
FilterInput,
Icon,
IconName,
LinkButton,
Stack,
TextLink,
Text,
Alert,
ConfirmModal,
Tab,
TabsBar,
TagList,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { DeleteRepositoryButton } from './DeleteRepositoryButton';
import GettingStarted from './GettingStarted/GettingStarted';
import GettingStartedPage from './GettingStarted/GettingStartedPage';
import { StatusBadge } from './StatusBadge';
import { SyncRepository } from './SyncRepository';
import { Repository, ResourceCount, useDeletecollectionRepositoryMutation, useGetFrontendSettingsQuery } from './api';
import { PROVISIONING_URL, CONNECT_URL } from './constants';
import { useRepositoryList } from './hooks';
import { checkSyncSettings } from './utils';
const appEvents = getAppEvents();
export default function RepositoryListPage() {
const [items, isLoading] = useRepositoryList({ watch: true });
const settings = useGetFrontendSettingsQuery();
const [deleteAll, deleteAllResult] = useDeletecollectionRepositoryMutation();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [activeTab, setActiveTab] = useState<string>('repositories');
useEffect(() => {
if (deleteAllResult.isSuccess) {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: ['All configured repositories deleted'],
});
}
}, [deleteAllResult.isSuccess]);
if (!items?.length && !isLoading) {
return <GettingStartedPage />;
}
const onConfirmDelete = () => {
deleteAll({});
setShowDeleteModal(false);
};
const renderTabContent = () => {
switch (activeTab) {
case 'repositories':
return <RepositoryListPageContent items={items ?? []} />;
case 'getting-started':
return <GettingStarted />;
default:
return null;
}
};
return (
<Page navId="provisioning" subTitle="View and manage your configured repositories">
<Page.Contents isLoading={isLoading}>
{settings.data?.legacyStorage && (
<Alert
title="Legacy storage detected"
severity="error"
buttonContent={<>Remove all configured repositories</>}
onRemove={() => {
setShowDeleteModal(true);
}}
>
Configured repositories will not work while running legacy storage.
</Alert>
)}
<ConfirmModal
isOpen={showDeleteModal}
title="Delete all configured repositories"
body="Are you sure you want to delete all configured repositories? This action cannot be undone."
confirmText="Delete repositories"
onConfirm={onConfirmDelete}
onDismiss={() => setShowDeleteModal(false)}
/>
<Stack direction="column" gap={2}>
<TabsBar>
<Tab
label="Repositories"
active={activeTab === 'repositories'}
onChangeTab={() => setActiveTab('repositories')}
/>
<Tab
label="Getting started"
active={activeTab === 'getting-started'}
onChangeTab={() => setActiveTab('getting-started')}
/>
</TabsBar>
{renderTabContent()}
</Stack>
</Page.Contents>
</Page>
);
}
function RepositoryListPageContent({ items }: { items: Repository[] }) {
const [query, setQuery] = useState('');
const filteredItems = items.filter((item) => item.metadata?.name?.includes(query));
const settings = useGetFrontendSettingsQuery();
const [instanceConnected] = checkSyncSettings(settings.data);
return (
<Stack direction={'column'} gap={3}>
<Stack gap={2}>
<FilterInput placeholder="Search" value={query} onChange={setQuery} />
{!instanceConnected && (
<LinkButton href={CONNECT_URL} variant="primary" icon={'plus'}>
Connect to repository
</LinkButton>
)}
</Stack>
<Stack direction={'column'}>
{!!filteredItems.length ? (
filteredItems.map((item) => {
const name = item.metadata?.name ?? '';
let icon: IconName = 'database'; // based on type
let meta: ReactNode[] = [];
switch (item.spec?.type) {
case 'github':
icon = 'github';
const spec = item.spec.github;
const url = item.spec.github?.url ?? '';
let branch = url;
if (spec?.branch) {
branch += `/tree/` + spec?.branch;
}
meta.push(
<TextLink key={'link'} external style={{ color: 'inherit' }} href={branch}>
{branch}
</TextLink>
);
if (item.status?.webhook?.id) {
const hook = url + `/settings/hooks/${item.status?.webhook?.id}`;
meta.push(
<TextLink key={'webhook'} style={{ color: 'inherit' }} href={hook}>
Webhook <Icon name={'check'} />
</TextLink>
);
}
break;
case 'local':
meta.push(
<Text element={'p'} key={'path'}>
{item.spec.local?.path ?? ''}
</Text>
);
break;
}
return (
<Card key={name}>
<Card.Figure>
<Icon name={icon} width={40} height={40} />
</Card.Figure>
<Card.Heading>
<Stack>
{item.spec?.title}{' '}
<StatusBadge
enabled={Boolean(item.spec?.sync?.enabled)}
state={item.status?.sync?.state}
name={name}
/>
<TagList tags={[item.spec?.sync?.target ?? '']} />
</Stack>
</Card.Heading>
<Card.Description>
{item.spec?.description}
{item.status?.stats?.length && (
<Stack>
{item.status.stats.map((v, index) => (
<LinkButton key={index} fill="outline" size="md" href={getListURL(item, v)}>
{v.count} {v.resource}
</LinkButton>
))}
</Stack>
)}
</Card.Description>
<Card.Meta>{meta}</Card.Meta>
<Card.Actions>
<LinkButton icon="eye" href={`${PROVISIONING_URL}/${name}`} variant="secondary">
View
</LinkButton>
<SyncRepository repository={item} />
<LinkButton variant="secondary" icon="cog" href={`${PROVISIONING_URL}/${name}/edit`}>
Settings
</LinkButton>
</Card.Actions>
<Card.SecondaryActions>
<DeleteRepositoryButton name={name} />
</Card.SecondaryActions>
</Card>
);
})
) : (
<EmptySearchResult>No results matching your query</EmptySearchResult>
)}
</Stack>
</Stack>
);
}
// This should return a URL in the UI that will show the selected values
function getListURL(repo: Repository, stats: ResourceCount): string {
if (stats.resource === 'playlists') {
return '/playlists';
}
if (repo.spec?.sync.target === 'folder') {
return `/dashboards/f/${repo.metadata?.name}`;
}
return '/dashboards';
}
@@ -1,48 +1,22 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useLocation } from 'react-router';
import { useParams } from 'react-router-dom-v5-compat';
import { SelectableValue, urlUtil } from '@grafana/data';
import {
Alert,
Button,
CellProps,
Column,
ConfirmModal,
EmptyState,
FilterInput,
InteractiveTable,
LinkButton,
Spinner,
Stack,
Tab,
TabContent,
TabsBar,
Text,
TextLink,
Modal,
} from '@grafana/ui';
import { Alert, EmptyState, Modal, Tab, TabContent, TabsBar, Text, TextLink } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { isNotFoundError } from '../alerting/unified/api/util';
import { ExportToRepository } from './ExportToRepository';
import { FilesView } from './FilesView';
import { MigrateToRepository } from './MigrateToRepository';
import { RepositoryActions } from './RepositoryActions';
import { RepositoryOverview } from './RepositoryOverview';
import { RepositoryResources } from './RepositoryResources';
import { StatusBadge } from './StatusBadge';
import { SyncRepository } from './SyncRepository';
import {
useGetRepositoryFilesQuery,
Repository,
useListRepositoryQuery,
useDeleteRepositoryFilesWithPathMutation,
useGetFrontendSettingsQuery,
} from './api';
import { FileDetails } from './api/types';
import { useGetFrontendSettingsQuery, useListRepositoryQuery } from './api';
import { PROVISIONING_URL } from './constants';
import { getRemoteURL } from './utils/git';
enum TabSelection {
Overview = 'overview',
@@ -70,9 +44,9 @@ export default function RepositoryStatusPage() {
const [queryParams] = useQueryParams();
const settings = useGetFrontendSettingsQuery();
const tab = queryParams['tab'] ?? TabSelection.Overview;
const remoteURL = data ? getRemoteURL(data) : undefined;
const notFound = query.isError && isNotFoundError(query.error);
return (
<Page
navId="provisioning"
@@ -82,27 +56,12 @@ export default function RepositoryStatusPage() {
}}
actions={
data && (
<Stack>
<StatusBadge enabled={Boolean(data.spec?.sync?.enabled)} state={data.status?.sync?.state} name={name} />
{remoteURL && (
<Button variant="secondary" icon="github" onClick={() => window.open(remoteURL, '_blank')}>
Source Code
</Button>
)}
<SyncRepository repository={data} />
{settings.data?.legacyStorage ? (
<Button variant="secondary" icon="cloud-upload" onClick={() => setShowMigrateModal(true)}>
Migrate
</Button>
) : (
<Button variant="secondary" icon="cloud-upload" onClick={() => setShowExportModal(true)}>
Push
</Button>
)}
<LinkButton variant="secondary" icon="cog" href={`${PROVISIONING_URL}/${name}/edit`}>
Settings
</LinkButton>
</Stack>
<RepositoryActions
repository={data}
showMigrateButton={settings.data?.legacyStorage}
onExportClick={() => setShowExportModal(true)}
onMigrateClick={() => setShowMigrateModal(true)}
/>
)
}
>
@@ -158,99 +117,3 @@ export default function RepositoryStatusPage() {
</Page>
);
}
interface RepoProps {
repo: Repository;
}
type FileCell<T extends keyof FileDetails = keyof FileDetails> = CellProps<FileDetails, FileDetails[T]>;
function FilesView({ repo }: RepoProps) {
const name = repo.metadata?.name ?? '';
const query = useGetRepositoryFilesQuery({ name });
const [deleteFile, deleteFileStatus] = useDeleteRepositoryFilesWithPathMutation();
const [searchQuery, setSearchQuery] = useState('');
const [pathToDelete, setPathToDelete] = useState<string>();
const data = [...(query.data?.items ?? [])].filter((file) =>
file.path.toLowerCase().includes(searchQuery.toLowerCase())
);
const columns: Array<Column<FileDetails>> = useMemo(
() => [
{
id: 'path',
header: 'Path',
sortType: 'string',
cell: ({ row: { original } }: FileCell<'path'>) => {
const { path } = original;
return <a href={`${PROVISIONING_URL}/${name}/file/${path}`}>{path}</a>;
},
},
{
id: 'size',
header: 'Size (KB)',
cell: ({ row: { original } }: FileCell<'size'>) => {
const { size } = original;
return (parseInt(size, 10) / 1024).toFixed(2);
},
sortType: 'number',
},
{
id: 'hash',
header: 'Hash',
sortType: 'string',
},
{
id: 'actions',
header: '',
cell: ({ row: { original } }: FileCell<'path'>) => {
const { path } = original;
return (
<Stack>
{(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && (
<LinkButton href={`${PROVISIONING_URL}/${name}/file/${path}`}>View</LinkButton>
)}
<LinkButton href={`${PROVISIONING_URL}/${name}/history/${path}`}>History</LinkButton>
<Button variant="destructive" onClick={() => setPathToDelete(path)}>
Delete
</Button>
</Stack>
);
},
},
],
[name]
);
if (query.isLoading) {
return (
<Stack justifyContent={'center'} alignItems={'center'}>
<Spinner />
</Stack>
);
}
return (
<Stack grow={1} direction={'column'} gap={2}>
<ConfirmModal
isOpen={Boolean(pathToDelete?.length) || deleteFileStatus.isLoading}
title="Delete file in repository?"
body={deleteFileStatus.isLoading ? 'Deleting file...' : pathToDelete}
confirmText="Delete"
icon={deleteFileStatus.isLoading ? `spinner` : `exclamation-triangle`}
onConfirm={() => {
deleteFile({
name: name,
path: pathToDelete!,
message: `Deleted from repo test UI`,
});
setPathToDelete('');
}}
onDismiss={() => setPathToDelete('')}
/>
<Stack gap={2}>
<FilterInput placeholder="Search" autoFocus={true} value={searchQuery} onChange={setSearchQuery} />
</Stack>
<InteractiveTable columns={columns} data={data} pageSize={25} getRowId={(f: FileDetails) => String(f.path)} />
</Stack>
);
}
@@ -29,9 +29,10 @@ const modeOptions = [
type Props = {
targetSelectable?: boolean;
generateName?: boolean;
};
export function ConnectionStep({ targetSelectable = true }: Props) {
export function ConnectionStep({ targetSelectable = true, generateName = false }: Props) {
const {
register,
control,
@@ -58,7 +59,12 @@ export function ConnectionStep({ targetSelectable = true }: Props) {
if (folderConnected) {
setValue('repository.sync.target', folderConnected ? 'folder' : 'instance');
}
}, [folderConnected, setValue]);
if (generateName) {
// Generate a unique title with timestamp to ensure uniqueness
const timestamp = Date.now();
setValue('repository.title', `instance-${timestamp}`);
}
}, [folderConnected, generateName, setValue]);
return (
<FieldSet label="1. Set up your repository connection details">
@@ -88,17 +94,20 @@ export function ConnectionStep({ targetSelectable = true }: Props) {
)}
/>
</Field>
<Field
label="Display name"
description="Add a clear name for this repository connection"
error={errors.repository?.title?.message}
invalid={!!errors.repository?.title}
>
<Input
{...register('repository.title', { required: 'This field is required.' })}
placeholder="My repository connection"
/>
</Field>
{/* Only show title field if not instance sync */}
{!generateName && (
<Field
label="Display name"
description="Add a clear name for this repository connection"
error={errors.repository?.title?.message}
invalid={!!errors.repository?.title}
>
<Input
{...register('repository.title', { required: 'This field is required.' })}
placeholder="My repository connection"
/>
</Field>
)}
<Stack direction="column" gap={2}>
{folderConnected && (
@@ -115,7 +115,9 @@ export function ProvisioningWizard({ requiresMigration }: Props) {
/>
<div className={styles.content}>
{activeStep === 'connection' && <ConnectionStep targetSelectable={!requiresMigration} />}
{activeStep === 'connection' && (
<ConnectionStep targetSelectable={!requiresMigration} generateName={requiresMigration} />
)}
{activeStep === 'repository' && <RepositoryStep onStatusChange={handleStatusChange} />}
{activeStep === 'migrate' && requiresMigration && <MigrateStep onStatusChange={handleStatusChange} />}
{activeStep === 'pull' && !requiresMigration && <PullStep onStatusChange={handleStatusChange} />}
@@ -24,7 +24,7 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
{
path: PROVISIONING_URL,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "RepositoryListPage"*/ 'app/features/provisioning/RepositoryListPage')
() => import(/* webpackChunkName: "RepositoryListPage"*/ 'app/features/provisioning/HomePage')
),
},
{