Provisioning: Onboarding updates (#103722)
* Fix status badge URL * Remove enhanced features from the overview page * Update TokenPermissionsInfo.tsx * Update ConnectionStep * Update Bootstrap step * Vertical stepper * Show enhanced features * Update step name * Fix stale settings data * Update steps * Revert Field changes * Do not require workflows * Show disabled buttons * Update final step's checkboxes * Add translations * Rename to RepositoryList * Remove type select * Merge pull and migrate steps * Fix migration job * Update text * Update card text * Fix repository link * Update progress bar * Refactor workflows into readOnly and prWorkflow * Remove todos * i18n * Fix translation * Update ConnectRepositoryButton.tsx * Add form prompt * Fix form prompt * Fix GH-only fields * Update copy * Cleanup * Add translations * Update translations * Update translations * Fix title
This commit is contained in:
@@ -15,8 +15,6 @@ export interface FieldProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactElement;
|
||||
/** Label for the field */
|
||||
label?: React.ReactNode;
|
||||
/** Forcibly use a Label, despite passing a non-string node. */
|
||||
useLabel?: boolean;
|
||||
/** Description of the field */
|
||||
description?: React.ReactNode;
|
||||
/** Indicates if field is in invalid state */
|
||||
@@ -47,7 +45,6 @@ export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
useLabel,
|
||||
description,
|
||||
horizontal,
|
||||
invalid,
|
||||
@@ -66,25 +63,14 @@ export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||
const styles = useStyles2(getFieldStyles);
|
||||
const inputId = htmlFor ?? getChildId(children);
|
||||
|
||||
let labelElement: React.ReactNode;
|
||||
if (typeof label === 'string') {
|
||||
labelElement = (
|
||||
const labelElement =
|
||||
typeof label === 'string' ? (
|
||||
<Label htmlFor={inputId} description={description}>
|
||||
{label + (required ? ' *' : '')}
|
||||
{`${label}${required ? ' *' : ''}`}
|
||||
</Label>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
} else if (useLabel) {
|
||||
labelElement = (
|
||||
<Label htmlFor={inputId} description={description}>
|
||||
<span>
|
||||
{label}
|
||||
{required ? ' *' : ''}
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
} else {
|
||||
labelElement = label;
|
||||
}
|
||||
|
||||
const childProps = deleteUndefinedProps({ invalid, disabled, loading });
|
||||
return (
|
||||
|
||||
+3
-7
@@ -13,7 +13,6 @@ export interface Props {
|
||||
}
|
||||
|
||||
export function ProvisionedResourceDeleteModal({ onDismiss, resource }: Props) {
|
||||
const type = isDashboard(resource) ? 'dashboard' : 'folder';
|
||||
return (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
@@ -25,12 +24,9 @@ export function ProvisionedResourceDeleteModal({ onDismiss, resource }: Props) {
|
||||
>
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="dashboard-scene.provisioned-resource-delete-modal.managed-by-version-control"
|
||||
values={{ type }}
|
||||
>
|
||||
This {type} is managed by version control and cannot be deleted. To remove it, delete it from the repository
|
||||
and synchronise to apply the changes.
|
||||
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.managed-by-version-control">
|
||||
This resource is managed by version control and cannot be deleted. To remove it, delete it from the
|
||||
repository and synchronise to apply the changes.
|
||||
</Trans>
|
||||
</p>
|
||||
{isDashboard(resource) && (
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
ComboboxOption,
|
||||
ControlledCollapse,
|
||||
Field,
|
||||
Input,
|
||||
MultiCombobox,
|
||||
RadioButtonGroup,
|
||||
SecretInput,
|
||||
Stack,
|
||||
@@ -17,34 +16,15 @@ import {
|
||||
} from '@grafana/ui';
|
||||
import { Repository, RepositorySpec } from 'app/api/clients/provisioning';
|
||||
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
|
||||
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
|
||||
import { RepositoryFormData, WorkflowOption } from '../types';
|
||||
import { RepositoryFormData } from '../types';
|
||||
import { dataToSpec, specToData } from '../utils/data';
|
||||
|
||||
import { ConfigFormGithubCollapse } from './ConfigFormGithubCollapse';
|
||||
|
||||
export function getWorkflowOptions(type?: 'github' | 'local'): Array<ComboboxOption<WorkflowOption>> {
|
||||
const opts: Array<ComboboxOption<WorkflowOption>> = [
|
||||
{
|
||||
label: t('provisioning.config-form.option-branch', 'Branch'),
|
||||
value: 'branch',
|
||||
description: t('provisioning.config-form.description-branch', 'Create a branch (and pull request) for changes'),
|
||||
},
|
||||
{
|
||||
label: t('provisioning.config-form.option-write', 'Write'),
|
||||
value: 'write',
|
||||
description: t('provisioning.config-form.description-write', 'Allow writing updates to the remote repository'),
|
||||
},
|
||||
];
|
||||
if (type === 'github') {
|
||||
return opts;
|
||||
}
|
||||
return opts.filter((opt) => opt.value === 'write'); // only write
|
||||
}
|
||||
|
||||
export function getDefaultValues(repository?: RepositorySpec): RepositoryFormData {
|
||||
if (!repository) {
|
||||
return {
|
||||
@@ -53,8 +33,9 @@ export function getDefaultValues(repository?: RepositorySpec): RepositoryFormDat
|
||||
token: '',
|
||||
url: '',
|
||||
branch: 'main',
|
||||
generateDashboardPreviews: true,
|
||||
workflows: ['branch', 'write'],
|
||||
generateDashboardPreviews: false,
|
||||
readOnly: false,
|
||||
prWorkflow: true,
|
||||
path: 'grafana/',
|
||||
sync: {
|
||||
enabled: false,
|
||||
@@ -84,7 +65,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
const isEdit = Boolean(data?.metadata?.name);
|
||||
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
|
||||
const navigate = useNavigate();
|
||||
const type = watch('type');
|
||||
const [type, readOnly] = watch(['type', 'readOnly']);
|
||||
|
||||
const typeOptions = useMemo(
|
||||
() => [
|
||||
@@ -241,33 +222,36 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={t('provisioning.config-form.label-workflows', 'Workflows')}
|
||||
required
|
||||
error={errors?.workflows?.message}
|
||||
invalid={!!errors?.workflows}
|
||||
description={t(
|
||||
'provisioning.config-form.description-workflows-makes-repository',
|
||||
'No workflows makes the repository read only'
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
name={'workflows'}
|
||||
control={control}
|
||||
rules={{ required: t('provisioning.config-form.error-required', 'This field is required.') }}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<MultiCombobox
|
||||
options={getWorkflowOptions(type)}
|
||||
placeholder={t('provisioning.config-form.placeholder-readonly-repository', 'Readonly repository')}
|
||||
onChange={(val) => {
|
||||
onChange(val.map((v) => v.value));
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
<Field>
|
||||
<Checkbox
|
||||
{...register('readOnly', {
|
||||
onChange: (e) => {
|
||||
if (e.target.checked) {
|
||||
setValue('prWorkflow', false);
|
||||
}
|
||||
},
|
||||
})}
|
||||
label={t('provisioning.finish-step.label-read-only', 'Read only')}
|
||||
description={t(
|
||||
'provisioning.config-form.description-read-only',
|
||||
"Resources can't be modified through Grafana."
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Checkbox
|
||||
disabled={readOnly}
|
||||
{...register('prWorkflow')}
|
||||
label={t('provisioning.config-form.label-pr-workflow', 'Enable pull request option when saving')}
|
||||
description={
|
||||
<Trans i18nKey="provisioning.finish-step.description-webhooks-enable">
|
||||
Allows users to choose whether to open a pull request when saving changes. If the repository does not
|
||||
allow direct changes to the main branch, a pull request may still be required.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{type === 'github' && (
|
||||
<ConfigFormGithubCollapse
|
||||
previews={<Switch {...register('generateDashboardPreviews')} id={'generateDashboardPreviews'} />}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack direction="column" gap={5}>
|
||||
<Stack direction="column">
|
||||
<Text variant="h4">
|
||||
<Trans i18nKey="provisioning.enhanced-features.header">Enhance your GitHub experience</Trans>
|
||||
@@ -28,7 +28,7 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} direction="row" height="100%">
|
||||
<Box width="40%" height="100%" padding={2} display="flex" direction="column" gap={2} alignItems="flex-start">
|
||||
<Box width="40%" height="100%" display="flex" direction="column" gap={2} alignItems="flex-start">
|
||||
<Stack gap={2}>
|
||||
<IconCircle icon="sync" color="blue" />
|
||||
<IconCircle icon="code-branch" color="purple" />
|
||||
@@ -44,16 +44,28 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
{!hasPublicAccess && (
|
||||
<LinkButton fill="outline" variant="secondary" onClick={onSetupPublicAccess}>
|
||||
<Trans i18nKey="provisioning.enhanced-features.set-up-public-webhooks">Set up public webhooks</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
<LinkButton
|
||||
fill="outline"
|
||||
variant="secondary"
|
||||
onClick={onSetupPublicAccess}
|
||||
disabled={hasPublicAccess}
|
||||
icon={hasPublicAccess ? 'check' : undefined}
|
||||
>
|
||||
<Trans i18nKey="provisioning.enhanced-features.set-up-public-webhooks">Set up public webhooks</Trans>
|
||||
</LinkButton>
|
||||
</Box>
|
||||
|
||||
<div className={style.separator} />
|
||||
|
||||
<Box width="40%" height="100%" padding={2} display="flex" direction="column" gap={2} alignItems="flex-start">
|
||||
<Box
|
||||
width="40%"
|
||||
height="100%"
|
||||
paddingLeft={2}
|
||||
display="flex"
|
||||
direction="column"
|
||||
gap={2}
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<IconCircle icon="camera" color="orange" />
|
||||
<Trans i18nKey="provisioning.enhanced-features.title-visual-previews-in-pull-requests">
|
||||
Visual previews in pull requests with image rendering
|
||||
@@ -65,16 +77,15 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
{hasImageRenderer && (
|
||||
<LinkButton
|
||||
fill="outline"
|
||||
variant="secondary"
|
||||
href="https://grafana.com/grafana/plugins/grafana-image-renderer/"
|
||||
icon="external-link-alt"
|
||||
>
|
||||
<Trans i18nKey="provisioning.enhanced-features.set-up-image-rendering">Set up image rendering</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
<LinkButton
|
||||
fill="outline"
|
||||
variant="secondary"
|
||||
href="https://grafana.com/grafana/plugins/grafana-image-renderer/"
|
||||
icon={hasImageRenderer ? 'check' : 'external-link-alt'}
|
||||
disabled={hasImageRenderer}
|
||||
>
|
||||
<Trans i18nKey="provisioning.enhanced-features.set-up-image-rendering">Set up image rendering</Trans>
|
||||
</LinkButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -34,11 +34,6 @@ export const FeaturesList = ({ repos, hasRequiredFeatures, onSetupFeatures }: Fe
|
||||
Store dashboards in version-controlled storage for better organization and history tracking
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans i18nKey="provisioning.features-list.migrate-existing-dashboards-storage-provisioning">
|
||||
Migrate existing dashboards to GitHub for provisioning
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
{!hasRequiredFeatures ? (
|
||||
<Box>
|
||||
|
||||
@@ -122,9 +122,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function GettingStarted({ items }: Props) {
|
||||
const settingsQuery = useGetFrontendSettingsQuery();
|
||||
const settingsQuery = useGetFrontendSettingsQuery(undefined, { refetchOnMountOrArgChange: true });
|
||||
const legacyStorage = settingsQuery.data?.legacyStorage;
|
||||
|
||||
const hasItems = Boolean(settingsQuery.data?.items?.length);
|
||||
const { hasPublicAccess, hasImageRenderer, hasRequiredFeatures } = getConfigurationStatus();
|
||||
const [showInstructionsModal, setShowModal] = useState(false);
|
||||
const [setupType, setSetupType] = useState<SetupType>(null);
|
||||
@@ -172,7 +172,7 @@ export default function GettingStarted({ items }: Props) {
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
{(!hasPublicAccess || !hasImageRenderer) && (
|
||||
{(!hasPublicAccess || !hasImageRenderer) && hasItems && (
|
||||
<EnhancedFeatures
|
||||
hasPublicAccess={hasPublicAccess}
|
||||
hasImageRenderer={hasImageRenderer}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import GettingStarted from './GettingStarted/GettingStarted';
|
||||
import GettingStartedPage from './GettingStarted/GettingStartedPage';
|
||||
import { FolderRepositoryList } from './Shared/FolderRepositoryList';
|
||||
import { RepositoryList } from './Shared/RepositoryList';
|
||||
import { useRepositoryList } from './hooks/useRepositoryList';
|
||||
|
||||
enum TabSelection {
|
||||
@@ -51,7 +51,7 @@ export default function HomePage() {
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case TabSelection.Repositories:
|
||||
return <FolderRepositoryList items={items ?? []} />;
|
||||
return <RepositoryList items={items ?? []} />;
|
||||
case TabSelection.GettingStarted:
|
||||
return <GettingStarted items={items ?? []} />;
|
||||
default:
|
||||
|
||||
@@ -18,6 +18,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
}
|
||||
|
||||
const { state, message, progress, summary } = job.status;
|
||||
const repoName = job.metadata?.labels?.['provisioning.grafana.app/repository'];
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
switch (state) {
|
||||
@@ -52,11 +53,11 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack direction="column" gap={2}>
|
||||
{getStatusDisplay()}
|
||||
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
|
||||
<ProgressBar progress={progress} />
|
||||
</Stack>
|
||||
|
||||
{state && !['success', 'error'].includes(state) && (
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
|
||||
<ProgressBar progress={progress ?? 0} />
|
||||
</Stack>
|
||||
)}
|
||||
{isFinishedJob && summary && (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text variant="h3">
|
||||
@@ -66,7 +67,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
</Stack>
|
||||
)}
|
||||
{state === 'success' ? (
|
||||
<RepositoryLink name={job.metadata?.labels?.repository} />
|
||||
<RepositoryLink name={repoName} />
|
||||
) : (
|
||||
<ControlledCollapse label={t('provisioning.job-status.label-view-details', 'View details')} isOpen={false}>
|
||||
<pre>{JSON.stringify(job, null, 2)}</pre>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
|
||||
import { LinkButton, Stack, Text } from '@grafana/ui';
|
||||
import { Stack, Text, TextLink } from '@grafana/ui';
|
||||
import { useGetRepositoryQuery } from 'app/api/clients/provisioning';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
@@ -19,7 +19,6 @@ export function RepositoryLink({ name }: RepositoryLinkProps) {
|
||||
}
|
||||
|
||||
const repoHref = getRepoHref(repo.spec?.github);
|
||||
const folderHref = repo.spec?.sync.target === 'folder' ? `/dashboards/f/${repo.metadata?.name}` : '/dashboards';
|
||||
|
||||
if (!repoHref) {
|
||||
return null;
|
||||
@@ -28,17 +27,15 @@ export function RepositoryLink({ name }: RepositoryLinkProps) {
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Text>
|
||||
<Trans i18nKey="provisioning.repository-link.grafana-repository">
|
||||
Grafana and your repository are now in sync.
|
||||
<Trans i18nKey="provisioning.repository-link.grafana-repository-synced">
|
||||
Your resources are now in your external storage and provisioned into your instance. From now on, your instance
|
||||
and the external storage will be synchronized.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Stack direction="row" gap={2}>
|
||||
<LinkButton fill="outline" href={repoHref} icon="external-link-alt" target="_blank" rel="noopener noreferrer">
|
||||
<TextLink href={repoHref} external>
|
||||
<Trans i18nKey="provisioning.repository-link.view-repository">View repository</Trans>
|
||||
</LinkButton>
|
||||
<LinkButton fill="outline" href={folderHref} icon="folder-open">
|
||||
<Trans i18nKey="provisioning.repository-link.view-folder">View folder</Trans>
|
||||
</LinkButton>
|
||||
</TextLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { Alert, LinkButton, Stack } from '@grafana/ui';
|
||||
import { Repository } from 'app/api/clients/provisioning';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { Alert, Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui';
|
||||
import { Repository } from 'app/api/clients/provisioning';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { RepoType } from '../Wizard/types';
|
||||
import { CONNECT_URL } from '../constants';
|
||||
import { checkSyncSettings } from '../utils/checkSyncSettings';
|
||||
|
||||
interface Props {
|
||||
items?: Repository[];
|
||||
showDropdown?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectRepositoryButton({ items }: Props) {
|
||||
type ConnectUrl = `${typeof CONNECT_URL}/${RepoType}`;
|
||||
|
||||
const gitURL: ConnectUrl = `${CONNECT_URL}/github`;
|
||||
const localURL: ConnectUrl = `${CONNECT_URL}/local`;
|
||||
|
||||
export function ConnectRepositoryButton({ items, showDropdown = false }: Props) {
|
||||
const state = checkSyncSettings(items);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (state.instanceConnected) {
|
||||
return null;
|
||||
@@ -28,12 +38,44 @@ export function ConnectRepositoryButton({ items }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (showDropdown) {
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
icon="code-branch"
|
||||
label={t('provisioning.connect-repository-button.configure-git-sync', 'Configure Git Sync')}
|
||||
onClick={() => {
|
||||
navigate(gitURL);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
icon="file-alt"
|
||||
label={t('provisioning.connect-repository-button.configure-file', 'Configure file provisioning')}
|
||||
onClick={() => {
|
||||
navigate(localURL);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button variant="primary">
|
||||
<Stack alignItems="center">
|
||||
<Trans i18nKey="provisioning.connect-repository-button.configure">Configure</Trans>
|
||||
<Icon name={'angle-down'} />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<LinkButton href={CONNECT_URL} variant="primary">
|
||||
<Trans i18nKey="provisioning.connect-repository-button.configure-git-sync">Configure GitSync</Trans>
|
||||
<LinkButton href={gitURL} variant="primary">
|
||||
<Trans i18nKey="provisioning.connect-repository-button.configure-git-sync">Configure Git Sync</Trans>
|
||||
</LinkButton>
|
||||
<LinkButton href={CONNECT_URL} variant="secondary">
|
||||
<LinkButton href={localURL} variant="secondary">
|
||||
<Trans i18nKey="provisioning.connect-repository-button.configure-file">Configure file provisioning</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
|
||||
@@ -31,7 +31,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
}),
|
||||
filler: css({
|
||||
height: '100%',
|
||||
background: theme.colors.gradients.brandHorizontal,
|
||||
background: theme.colors.success.text,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: 'width 0.5s ease-in-out',
|
||||
},
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ interface Props {
|
||||
items: Repository[];
|
||||
}
|
||||
|
||||
export function FolderRepositoryList({ items }: Props) {
|
||||
export function RepositoryList({ items }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const filteredItems = items.filter((item) => item.metadata?.name?.includes(query));
|
||||
const { instanceConnected } = checkSyncSettings(items);
|
||||
@@ -26,7 +26,7 @@ export function FolderRepositoryList({ items }: Props) {
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
/>
|
||||
<ConnectRepositoryButton items={items} />
|
||||
<ConnectRepositoryButton items={items} showDropdown />
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction={'column'}>
|
||||
@@ -63,7 +63,7 @@ export function StatusBadge({ repo }: StatusBadgeProps) {
|
||||
style={{ cursor: 'pointer' }}
|
||||
tooltip={tooltip}
|
||||
onClick={() => {
|
||||
locationService.push(`${PROVISIONING_URL}/${name}/?tab=overview`);
|
||||
locationService.push(`${PROVISIONING_URL}/${repo.metadata?.name}/?tab=overview`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { Stack, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
export function TokenPermissionsInfo() {
|
||||
@@ -9,60 +9,36 @@ export function TokenPermissionsInfo() {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.github-instructions">
|
||||
Go to{' '}
|
||||
<TextLink external href="https://github.com/settings/personal-access-tokens/new">
|
||||
GitHub Personal Access Tokens
|
||||
</TextLink>
|
||||
. Make sure to include these permissions under <b>Repository</b>:
|
||||
</Trans>
|
||||
</div>
|
||||
{/* GitHub UI is English only, so these strings are not translated */}
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Stack gap={0.5}>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.go-to">Go to</Trans>
|
||||
<TextLink external href="https://github.com/settings/personal-access-tokens/new">
|
||||
GitHub Personal Access Tokens
|
||||
</TextLink>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.and-click">and click</Trans>
|
||||
<strong>"Fine-grained token".</strong>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.make-sure">Make sure to include these permissions</Trans>:
|
||||
</Stack>
|
||||
|
||||
<table className={styles.permissionTable}>
|
||||
<tbody>
|
||||
<tr className={styles.headerSeparator}>
|
||||
<th>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.permission">Permission</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.access">Access</Trans>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.contents">Contents</Trans>
|
||||
</td>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.read-and-write">Read and write</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.metadata">Metadata</Trans>
|
||||
</td>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.readonly">Read-only</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.pull-requests">Pull requests</Trans>
|
||||
</td>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.read-and-write">Read and write</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.webhooks">Webhooks</Trans>
|
||||
</td>
|
||||
<td>
|
||||
<Trans i18nKey="provisioning.token-permissions-info.read-and-write">Read and write</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul className={styles.permissionsList}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<li>
|
||||
Content: <span className={styles.accessLevel}>Read and write</span>
|
||||
</li>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<li>
|
||||
Metadata: <span className={styles.accessLevel}>Read only</span>
|
||||
</li>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<li>
|
||||
Pull requests: <span className={styles.accessLevel}>Read and write</span>
|
||||
</li>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<li>
|
||||
Webhooks: <span className={styles.accessLevel}>Read and write</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,22 +47,27 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
container: css({
|
||||
marginBottom: theme.spacing(1),
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
position: 'relative',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 0',
|
||||
padding: theme.spacing(theme.components.panel.padding),
|
||||
}),
|
||||
permissionTable: css({
|
||||
tableLayout: 'auto',
|
||||
width: '40%',
|
||||
permissionsList: css({
|
||||
marginTop: theme.spacing(2),
|
||||
marginBottom: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(3),
|
||||
|
||||
li: css({
|
||||
marginBottom: theme.spacing(1),
|
||||
}),
|
||||
}),
|
||||
headerSeparator: css({
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
accessLevel: css({
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
background: '#22262B',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
padding: theme.spacing(0.25, 0.5),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,24 +93,22 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
|
||||
<Box alignItems="center" padding={4}>
|
||||
<Stack direction="row" gap={4} alignItems="flex-start" justifyContent="center">
|
||||
<Stack direction="column" gap={1} alignItems="center">
|
||||
<Text variant="h4" color="secondary">
|
||||
<Trans i18nKey="provisioning.bootstrap-step.grafana">Grafana</Trans>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="provisioning.bootstrap-step.grafana">Grafana instance</Trans>
|
||||
</Text>
|
||||
<Stack direction="row" gap={2}>
|
||||
<Text variant="h3">
|
||||
<Text variant="h3">
|
||||
{state.resourceCount > 0
|
||||
? state.resourceCountString
|
||||
: t('provisioning.bootstrap-step.empty', 'Empty')}
|
||||
</Text>
|
||||
<Text variant="h4">
|
||||
{state.resourceCount > 0
|
||||
? state.resourceCountString
|
||||
: t('provisioning.bootstrap-step.empty', 'Empty')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="column" gap={1} alignItems="center">
|
||||
<Text variant="h4" color="secondary">
|
||||
<Trans i18nKey="provisioning.bootstrap-step.repository">Repository</Trans>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="provisioning.bootstrap-step.ext-storage">External storage</Trans>
|
||||
</Text>
|
||||
<Text variant="h3">
|
||||
<Text variant="h4">
|
||||
{state.fileCount > 0
|
||||
? t('provisioning.bootstrap-step.files-count', '{{count}} files', { count: state.fileCount })
|
||||
: t('provisioning.bootstrap-step.empty', 'Empty')}
|
||||
@@ -134,7 +132,12 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
|
||||
autoFocus={index === 0}
|
||||
>
|
||||
<Card.Heading>{action.label}</Card.Heading>
|
||||
<Card.Description>{action.description}</Card.Description>
|
||||
<Card.Description>
|
||||
<Stack direction="column" gap={3}>
|
||||
{action.description}
|
||||
<Text color="primary">{action.subtitle}</Text>
|
||||
</Stack>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
@@ -199,6 +202,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
|
||||
)}
|
||||
error={errors.repository?.title?.message}
|
||||
invalid={!!errors.repository?.title}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('repository.title', {
|
||||
@@ -208,7 +212,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
|
||||
'provisioning.bootstrap-step.placeholder-my-repository-connection',
|
||||
'My repository connection'
|
||||
)}
|
||||
// Auto-focus the title field if it's the only available option
|
||||
// Autofocus the title field if it's the only available option
|
||||
autoFocus={state.actions.length === 1 && state.actions[0].target === 'folder'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { ProvisioningWizard } from './ProvisioningWizard';
|
||||
import { RepoType } from './types';
|
||||
|
||||
export default function ConnectPage() {
|
||||
const { type } = useParams<{ type: RepoType }>();
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
navId="provisioning"
|
||||
pageNav={{
|
||||
text: 'Connect to external storage',
|
||||
text: type === 'github' ? 'Configure Git Sync' : 'Configure local file path',
|
||||
subTitle: 'Connect to an external storage to manage your resources',
|
||||
}}
|
||||
>
|
||||
<Page.Contents>
|
||||
<ProvisioningWizard />
|
||||
<ProvisioningWizard type={type} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Combobox, ComboboxOption, Field, Input, SecretInput, Stack } from '@grafana/ui';
|
||||
import { Field, Input, SecretInput, Stack } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { getWorkflowOptions } from '../Config/ConfigForm';
|
||||
import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
|
||||
|
||||
import { WizardFormData } from './types';
|
||||
@@ -13,60 +12,22 @@ export function ConnectStep() {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
} = useFormContext<WizardFormData>();
|
||||
|
||||
const type = watch('repository.type');
|
||||
const [tokenConfigured, setTokenConfigured] = useState(false);
|
||||
|
||||
const typeOptions = useMemo<Array<ComboboxOption<'github' | 'local'>>>(
|
||||
() => [
|
||||
{ label: t('provisioning.connect-step.storage-type-github', 'GitHub'), value: 'github' },
|
||||
{ label: t('provisioning.connect-step.storage-type-local', 'Local'), value: 'local' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const type = getValues('repository.type');
|
||||
const isGithub = type === 'github';
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Field
|
||||
label={t('provisioning.connect-step.label-storage-type', 'Storage type')}
|
||||
required
|
||||
description={t(
|
||||
'provisioning.connect-step.description-choose-storage-resources',
|
||||
'Choose the type of storage for your resources'
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
name={'repository.type'}
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Combobox
|
||||
options={typeOptions}
|
||||
onChange={(value) => {
|
||||
const repoType = value?.value;
|
||||
onChange(repoType);
|
||||
setValue(
|
||||
'repository.workflows',
|
||||
getWorkflowOptions(repoType).map((v) => v.value)
|
||||
);
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isGithub && (
|
||||
<>
|
||||
<TokenPermissionsInfo />
|
||||
<Field
|
||||
label={t('provisioning.connect-step.label-access-token', 'Enter your access token')}
|
||||
label={t('provisioning.connect-step.label-access-token', 'GitHub access token')}
|
||||
required
|
||||
description={t(
|
||||
'provisioning.connect-step.description-paste-your-git-hub-personal-access-token',
|
||||
@@ -100,7 +61,7 @@ export function ConnectStep() {
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t('provisioning.connect-step.label-repository-url', 'Enter your Repository URL')}
|
||||
label={t('provisioning.connect-step.label-repository-url', 'GitHub repository URL')}
|
||||
error={errors.repository?.url?.message}
|
||||
invalid={!!errors.repository?.url}
|
||||
description={t(
|
||||
@@ -126,7 +87,8 @@ export function ConnectStep() {
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t('provisioning.connect-step.label-branch', 'Branch')}
|
||||
label={t('provisioning.connect-step.label-branch', 'Branch name')}
|
||||
description={t('provisioning.connect-step.description-branch', 'Branch to use for the GitHub repository')}
|
||||
error={errors.repository?.branch?.message}
|
||||
invalid={!!errors.repository?.branch}
|
||||
>
|
||||
@@ -137,12 +99,12 @@ export function ConnectStep() {
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t('provisioning.connect-step.label-path', 'Path')}
|
||||
label={t('provisioning.connect-step.label-path', 'Path to subdirectory in repository')}
|
||||
error={errors.repository?.path?.message}
|
||||
invalid={!!errors.repository?.path}
|
||||
description={t(
|
||||
'provisioning.connect-step.description-github-path',
|
||||
'Path to a subdirectory in the Git repository'
|
||||
'This is the path to a subdirectory in your GitHub repository where dashboards will be stored and provisioned from'
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Field, Input, MultiCombobox, Stack, Switch, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { Checkbox, Field, Input, Stack, Text, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { getWorkflowOptions } from '../Config/ConfigForm';
|
||||
import { checkPublicAccess, checkImageRenderer } from '../GettingStarted/features';
|
||||
|
||||
import { WizardFormData } from './types';
|
||||
|
||||
export function FinishStep() {
|
||||
const { register, watch, control, formState } = useFormContext<WizardFormData>();
|
||||
const { errors } = formState;
|
||||
const { register, watch, setValue } = useFormContext<WizardFormData>();
|
||||
|
||||
const type = watch('repository.type');
|
||||
const [type, readOnly] = watch(['repository.type', 'repository.readOnly']);
|
||||
const isGithub = type === 'github';
|
||||
const isPublic = checkPublicAccess();
|
||||
const hasImageRenderer = checkImageRenderer();
|
||||
// Enable sync by default
|
||||
const { setValue } = useFormContext<WizardFormData>();
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
if (!isPublic || !hasImageRenderer) {
|
||||
if (formState.defaultValues?.repository) {
|
||||
formState.defaultValues.repository.generateDashboardPreviews = false;
|
||||
}
|
||||
}
|
||||
if (!isPublic) {
|
||||
if (formState.defaultValues?.repository) {
|
||||
// TODO: Disable webhooks by default
|
||||
}
|
||||
}
|
||||
|
||||
// Set sync enabled by default
|
||||
useEffect(() => {
|
||||
@@ -61,92 +43,81 @@ export function FinishStep() {
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={t('provisioning.finish-step.label-workflows', 'Workflows')}
|
||||
description={t(
|
||||
'provisioning.finish-step.description-select-workflows-allowed-within-repository',
|
||||
'Select the workflows that are allowed within this repository'
|
||||
)}
|
||||
required
|
||||
error={errors.repository?.workflows?.message}
|
||||
invalid={!!errors.repository?.workflows}
|
||||
>
|
||||
<Controller
|
||||
name="repository.workflows"
|
||||
control={control}
|
||||
rules={{ required: t('provisioning.finish-step.error-field-required', 'This field is required.') }}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<MultiCombobox
|
||||
options={getWorkflowOptions(type)}
|
||||
placeholder={t('provisioning.finish-step.placeholder-readonly-repository', 'Read-only repository')}
|
||||
onChange={(val) => {
|
||||
onChange(val.map((v) => v.value));
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
<Field>
|
||||
<Checkbox
|
||||
{...register('repository.readOnly', {
|
||||
onChange: (e) => {
|
||||
if (e.target.checked) {
|
||||
setValue('repository.prWorkflow', false);
|
||||
}
|
||||
},
|
||||
})}
|
||||
label={t('provisioning.finish-step.label-read-only', 'Read only')}
|
||||
description={t(
|
||||
'provisioning.finish-step.description-read-only',
|
||||
"Resources can't be modified through Grafana."
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isGithub && false /* TODO */ && (
|
||||
<Field
|
||||
label={
|
||||
t(
|
||||
'provisioning.finish-step.label-enable-webhooks',
|
||||
'Enable webhooks on changes'
|
||||
) /* TODO: Link to docs when !isPublic */
|
||||
}
|
||||
description={t(
|
||||
'provisioning.finish-step.description-enable-webhooks',
|
||||
'Enable webhooks to automatically notify Grafana when a change occurs in the repository. This will allow Grafana to pull changes as soon as they are made.'
|
||||
)}
|
||||
disabled={!isPublic}
|
||||
>
|
||||
{/* TODO: Make an option for the switch to control */}
|
||||
<Switch id="repository.webhook.enable" />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isGithub && (
|
||||
<Field
|
||||
useLabel
|
||||
label={
|
||||
<span>
|
||||
{t(
|
||||
'provisioning.finish-step.label-enable-dashboard-previews',
|
||||
'Enable dashboard previews in pull requests'
|
||||
)}{' '}
|
||||
<span className={style.explanation}>
|
||||
{t('provisioning.finish-step.text-requires-image-rendering', '(Requires image rendering.')}{' '}
|
||||
<a className={style.explanationLink} href="https://grafana.com">
|
||||
{t('provisioning.finish-step.link-setup-image-rendering', 'Set up image rendering')}
|
||||
</a>
|
||||
{')'}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
description={t(
|
||||
'provisioning.finish-step.description-dashboard-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.'
|
||||
)}
|
||||
disabled={!hasImageRenderer || !isPublic}
|
||||
>
|
||||
<Switch {...register('repository.generateDashboardPreviews')} id="repository.generateDashboardPreviews" />
|
||||
</Field>
|
||||
<>
|
||||
<Field>
|
||||
<Checkbox
|
||||
{...register('repository.prWorkflow')}
|
||||
disabled={readOnly}
|
||||
label={t('provisioning.finish-step.label-pr-workflow', 'Enable pull request option when saving')}
|
||||
description={
|
||||
<Trans i18nKey="provisioning.finish-step.description-pr-enable-description">
|
||||
Allows users to choose whether to open a pull request when saving changes. If the repository does not
|
||||
allow direct changes to the main branch, a pull request may still be required.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Text element="h4">
|
||||
<Trans i18nKey="provisioning.finish-step.title-enhance-github">Enhance your GitHub experience</Trans>
|
||||
</Text>
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
<Trans i18nKey="provisioning.finish-step.text-setup-later">You can always set this up later</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
<Field>
|
||||
<Checkbox
|
||||
disabled={!hasImageRenderer || !isPublic}
|
||||
label={t(
|
||||
'provisioning.finish-step.label-enable-previews',
|
||||
'Enable dashboard previews in pull requests'
|
||||
)}
|
||||
description={
|
||||
<>
|
||||
<Trans i18nKey="provisioning.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.
|
||||
</Trans>{' '}
|
||||
<Text italic>
|
||||
<Trans i18nKey="provisioning.finish-step.description-image-rendering">
|
||||
Requires image rendering.{' '}
|
||||
<TextLink
|
||||
variant="bodySmall"
|
||||
external
|
||||
href="https://grafana.com/grafana/plugins/grafana-image-renderer"
|
||||
>
|
||||
Set up image rendering
|
||||
</TextLink>
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
{...register('repository.generateDashboardPreviews')}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
explanation: css({
|
||||
color: theme.colors.text.disabled,
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
explanationLink: css({
|
||||
color: theme.colors.text.link,
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { Stack, Text } from '@grafana/ui';
|
||||
import { Job } from 'app/api/clients/provisioning';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { JobStatus } from '../Job/JobStatus';
|
||||
import { StepStatus, useStepStatus } from '../hooks/useStepStatus';
|
||||
|
||||
import { WizardFormData } from './types';
|
||||
|
||||
interface JobStepProps {
|
||||
onStepUpdate: (status: StepStatus, error?: string) => void;
|
||||
description: ReactNode;
|
||||
startJob: (repositoryName: string) => Promise<Job>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export type { JobStepProps };
|
||||
|
||||
export function JobStep({ onStepUpdate, description, startJob, children }: JobStepProps) {
|
||||
const { watch } = useFormContext<WizardFormData>();
|
||||
const repositoryName = watch('repositoryName');
|
||||
const stepStatus = useStepStatus({ onStepUpdate });
|
||||
const [job, setJob] = useState<Job>();
|
||||
|
||||
// Set initial running state outside the async operation
|
||||
useAsync(async () => {
|
||||
// Skip if we don't have a repository name or if we already started the job
|
||||
if (!repositoryName || job) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set running state when we're actually going to start the job
|
||||
stepStatus.setRunning();
|
||||
|
||||
try {
|
||||
const response = await startJob(repositoryName);
|
||||
if (!response?.metadata?.name) {
|
||||
throw new Error(t('provisioning.job-step.error-invalid-response', 'Invalid response from operation'));
|
||||
}
|
||||
setJob(response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('provisioning.job-step.error-failed-to-start', 'Failed to start operation');
|
||||
stepStatus.setError(errorMessage);
|
||||
throw error; // Re-throw to mark the async operation as failed
|
||||
}
|
||||
}, [repositoryName, job, setJob]); // Only depend on values that determine if we should start the job
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
{description && <Text color="secondary">{description}</Text>}
|
||||
{children}
|
||||
|
||||
{job && (
|
||||
<JobStatus
|
||||
watch={job}
|
||||
onStatusChange={(success) => {
|
||||
if (success) {
|
||||
stepStatus.setSuccess();
|
||||
} else {
|
||||
stepStatus.setError(t('provisioning.job-step.error-job-failed', 'Job failed'));
|
||||
}
|
||||
}}
|
||||
onRunningChange={(isRunning) => {
|
||||
if (isRunning) {
|
||||
stepStatus.setRunning();
|
||||
}
|
||||
}}
|
||||
onErrorChange={(error) => {
|
||||
if (error) {
|
||||
stepStatus.setError(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { useCreateRepositoryJobsMutation } from 'app/api/clients/provisioning';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { StepStatus } from '../hooks/useStepStatus';
|
||||
|
||||
import { JobStep } from './JobStep';
|
||||
import { WizardFormData } from './types';
|
||||
|
||||
export interface MigrateStepProps {
|
||||
onStepUpdate: (status: StepStatus, error?: string) => void;
|
||||
}
|
||||
|
||||
export function MigrateStep({ onStepUpdate }: MigrateStepProps) {
|
||||
const [createJob] = useCreateRepositoryJobsMutation();
|
||||
const { watch } = useFormContext<WizardFormData>();
|
||||
const history = watch('migrate.history');
|
||||
|
||||
const startMigration = async (repositoryName: string) => {
|
||||
const response = await createJob({
|
||||
name: repositoryName,
|
||||
jobSpec: {
|
||||
migrate: {
|
||||
history,
|
||||
},
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return (
|
||||
<JobStep
|
||||
onStepUpdate={onStepUpdate}
|
||||
description={t(
|
||||
'provisioning.migrate-step.description-migrating-dashboards',
|
||||
'Migrating all dashboards from this instance to your repository, including their identifiers and complete history. After this one-time migration, all future updates will be automatically saved to the repository.'
|
||||
)}
|
||||
startJob={startMigration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import { PROVISIONING_URL } from '../constants';
|
||||
|
||||
import { Step } from './Stepper';
|
||||
import { WizardContent } from './WizardContent';
|
||||
import { WizardFormData, WizardStep } from './types';
|
||||
import { RepoType, WizardFormData, WizardStep } from './types';
|
||||
|
||||
export function ProvisioningWizard() {
|
||||
export function ProvisioningWizard({ type }: { type: RepoType }) {
|
||||
const [activeStep, setActiveStep] = useState<WizardStep>('connection');
|
||||
const [completedSteps, setCompletedSteps] = useState<WizardStep[]>([]);
|
||||
const [stepSuccess, setStepSuccess] = useState(false);
|
||||
@@ -31,26 +31,20 @@ export function ProvisioningWizard() {
|
||||
},
|
||||
{
|
||||
id: 'bootstrap',
|
||||
name: t('provisioning.wizard.step-bootstrap', 'Bootstrap'),
|
||||
title: t('provisioning.wizard.title-bootstrap', 'Bootstrap repository'),
|
||||
name: t('provisioning.wizard.step-bootstrap', 'Choose what to synchronize'),
|
||||
title: t('provisioning.wizard.title-bootstrap', 'Choose what to synchronize'),
|
||||
submitOnNext: true,
|
||||
},
|
||||
{
|
||||
id: 'migrate',
|
||||
name: t('provisioning.wizard.step-resources', 'Resources'),
|
||||
title: t('provisioning.wizard.title-migrate', 'Migrate resources'),
|
||||
submitOnNext: false,
|
||||
},
|
||||
{
|
||||
id: 'pull',
|
||||
name: t('provisioning.wizard.step-resources', 'Resources'),
|
||||
title: t('provisioning.wizard.title-pull', 'Pull resources'),
|
||||
id: 'synchronize',
|
||||
name: t('provisioning.wizard.step-synchronize', 'Synchronize'),
|
||||
title: t('provisioning.wizard.title-synchronize', 'Synchronize with external storage'),
|
||||
submitOnNext: false,
|
||||
},
|
||||
{
|
||||
id: 'finish',
|
||||
name: t('provisioning.wizard.step-finish', 'Finish'),
|
||||
title: t('provisioning.wizard.title-finish', 'Finish setup'),
|
||||
name: t('provisioning.wizard.step-finish', 'Choose additional settings'),
|
||||
title: t('provisioning.wizard.title-finish', 'Choose additional settings'),
|
||||
submitOnNext: true,
|
||||
},
|
||||
],
|
||||
@@ -59,7 +53,7 @@ export function ProvisioningWizard() {
|
||||
|
||||
const methods = useForm<WizardFormData>({
|
||||
defaultValues: {
|
||||
repository: values,
|
||||
repository: { ...values, type },
|
||||
migrate: {
|
||||
history: true,
|
||||
identifier: true, // Keep the same URLs
|
||||
@@ -77,30 +71,24 @@ export function ProvisioningWizard() {
|
||||
[activeStep]
|
||||
);
|
||||
|
||||
// Filter out migrate step if using legacy storage
|
||||
const availableSteps = useMemo(() => {
|
||||
return requiresMigration
|
||||
? steps.filter((step) => step.id !== 'pull')
|
||||
: steps.filter((step) => step.id !== 'migrate');
|
||||
}, [requiresMigration, steps]);
|
||||
|
||||
// Calculate button text based on current step position
|
||||
const getNextButtonText = useCallback(
|
||||
(currentStep: WizardStep) => {
|
||||
const stepIndex = availableSteps.findIndex((s) => s.id === currentStep);
|
||||
if (currentStep === 'bootstrap') {
|
||||
return t('provisioning.wizard.button-start', 'Start');
|
||||
const stepIndex = steps.findIndex((s) => s.id === currentStep);
|
||||
|
||||
// Guard against index out of bounds
|
||||
if (stepIndex === -1 || stepIndex >= steps.length - 1) {
|
||||
return t('provisioning.wizard.button-next', 'Finish');
|
||||
}
|
||||
return stepIndex === availableSteps.length - 1
|
||||
? t('provisioning.wizard.button-finish', 'Finish')
|
||||
: t('provisioning.wizard.button-next', 'Next');
|
||||
|
||||
return steps[stepIndex + 1].name;
|
||||
},
|
||||
[availableSteps]
|
||||
[steps]
|
||||
);
|
||||
|
||||
const handleNext = async () => {
|
||||
const currentStepIndex = availableSteps.findIndex((s) => s.id === activeStep);
|
||||
const isLastStep = currentStepIndex === availableSteps.length - 1;
|
||||
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
|
||||
if (activeStep === 'connection') {
|
||||
// Validate repository form data before proceeding
|
||||
@@ -114,7 +102,7 @@ export function ProvisioningWizard() {
|
||||
switch (current.repository.type) {
|
||||
case 'github':
|
||||
const name = current.repository.url ?? 'github';
|
||||
methods.setValue('repository.title', name.replace('https://github/', ''));
|
||||
methods.setValue('repository.title', name.replace('https://github.com/', ''));
|
||||
break;
|
||||
case 'local':
|
||||
methods.setValue('repository.title', current.repository.path ?? 'local');
|
||||
@@ -122,13 +110,6 @@ export function ProvisioningWizard() {
|
||||
}
|
||||
}
|
||||
|
||||
// If we're on the bootstrap step, determine the next step based on the migration flag
|
||||
if (activeStep === 'bootstrap') {
|
||||
const nextStep = requiresMigration ? 'migrate' : 'pull';
|
||||
setActiveStep(nextStep);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only navigate to provisioning URL if we're on the actual last step and it's completed
|
||||
if (isLastStep && stepSuccess) {
|
||||
settingsQuery.refetch();
|
||||
@@ -137,8 +118,8 @@ export function ProvisioningWizard() {
|
||||
}
|
||||
|
||||
// For all other cases, proceed to next step
|
||||
if (currentStepIndex < availableSteps.length - 1) {
|
||||
setActiveStep(availableSteps[currentStepIndex + 1].id);
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setActiveStep(steps[currentStepIndex + 1].id);
|
||||
setStepSuccess(false);
|
||||
// Update completed steps only if the current step was successful
|
||||
if (stepSuccess) {
|
||||
@@ -152,7 +133,7 @@ export function ProvisioningWizard() {
|
||||
<WizardContent
|
||||
activeStep={activeStep}
|
||||
completedSteps={completedSteps}
|
||||
availableSteps={availableSteps}
|
||||
availableSteps={steps}
|
||||
requiresMigration={requiresMigration}
|
||||
handleStatusChange={handleStatusChange}
|
||||
handleNext={handleNext}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCreateRepositoryJobsMutation } from 'app/api/clients/provisioning';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { StepStatus } from '../hooks/useStepStatus';
|
||||
|
||||
import { JobStep } from './JobStep';
|
||||
|
||||
interface PullStepProps {
|
||||
onStepUpdate: (status: StepStatus, error?: string) => void;
|
||||
}
|
||||
|
||||
export function PullStep({ onStepUpdate }: PullStepProps) {
|
||||
const [createJob] = useCreateRepositoryJobsMutation();
|
||||
|
||||
const startSync = async (repositoryName: string) => {
|
||||
const response = await createJob({
|
||||
name: repositoryName,
|
||||
jobSpec: {
|
||||
pull: {
|
||||
incremental: false, // will queue a full resync job
|
||||
},
|
||||
},
|
||||
}).unwrap();
|
||||
return response;
|
||||
};
|
||||
|
||||
return (
|
||||
<JobStep
|
||||
onStepUpdate={onStepUpdate}
|
||||
description={t(
|
||||
'provisioning.pull-step.description-pulling-content',
|
||||
'Pulling all content from your repository to this Grafana instance. This ensures your dashboards and other resources are synchronized with the repository.'
|
||||
)}
|
||||
startJob={startSync}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Icon } from '@grafana/ui';
|
||||
|
||||
import { ValidationResult } from './types';
|
||||
|
||||
export interface Step<T> {
|
||||
id: T;
|
||||
name: string;
|
||||
@@ -17,41 +15,35 @@ export interface Props<T extends string | number> {
|
||||
reportId?: string;
|
||||
visitedSteps?: T[];
|
||||
steps: Array<Step<T>>;
|
||||
validationResults: Record<T, ValidationResult>;
|
||||
}
|
||||
|
||||
export function Stepper<T extends string | number>({
|
||||
validationResults,
|
||||
visitedSteps = [],
|
||||
steps,
|
||||
activeStep = steps[0]?.id,
|
||||
}: Props<T>) {
|
||||
export function Stepper<T extends string | number>({ visitedSteps = [], steps, activeStep = steps[0]?.id }: Props<T>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const lastStep = steps[steps.length - 1];
|
||||
|
||||
return (
|
||||
<ol className={styles.container}>
|
||||
{steps.map((step) => {
|
||||
const isLast = step.id === lastStep.id;
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === activeStep;
|
||||
const isVisited = visitedSteps.includes(step.id);
|
||||
const hasMissingFields = !validationResults[step.id].valid;
|
||||
const showIndicator = !isActive && isVisited;
|
||||
const successField = showIndicator && !hasMissingFields;
|
||||
const warnField = showIndicator && hasMissingFields;
|
||||
const itemStyles = cx(styles.item, {
|
||||
[styles.active]: isActive,
|
||||
[styles.successItem]: successField,
|
||||
[styles.warnItem]: warnField,
|
||||
const isCompleted = visitedSteps.includes(step.id) && !isActive;
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
const stepTextClass = cx(styles.stepText, {
|
||||
[styles.activeStepText]: isActive,
|
||||
});
|
||||
|
||||
return (
|
||||
<li key={step.id} className={itemStyles}>
|
||||
{successField && <Icon name={'check'} size={'xl'} className={styles.successItem} />}
|
||||
{warnField && <Icon name={'exclamation-triangle'} className={styles.warnItem} />}
|
||||
<div className={styles.link}>{step.name}</div>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
{!isLast && <div className={styles.divider}>—</div>}
|
||||
<li key={step.id} className={styles.stepContainer}>
|
||||
<div className={styles.stepContent}>
|
||||
{isCompleted ? (
|
||||
<div className={cx(styles.stepNumber, styles.completedStepNumber)}>
|
||||
<Icon name="check" size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.stepNumber}>{index + 1}</div>
|
||||
)}
|
||||
<div className={stepTextClass}>{step.name}</div>
|
||||
</div>
|
||||
{!isLast && <div className={styles.connector} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -62,54 +54,52 @@ export function Stepper<T extends string | number>({
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
counterReset: 'item',
|
||||
listStyleType: 'none',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
margin: theme.spacing(4, 0),
|
||||
flexDirection: 'column',
|
||||
margin: theme.spacing(2, 0),
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
width: 200,
|
||||
}),
|
||||
item: css({
|
||||
color: theme.colors.text.secondary,
|
||||
stepContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative',
|
||||
}),
|
||||
stepContent: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(0.5, 0),
|
||||
}),
|
||||
successItem: css({
|
||||
'a::before': {
|
||||
content: '""',
|
||||
},
|
||||
svg: {
|
||||
color: theme.colors.success.text,
|
||||
margin: theme.spacing(0, 0.5, 0, -1),
|
||||
},
|
||||
stepNumber: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: theme.spacing(3),
|
||||
width: theme.spacing(3),
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.size.sm,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
warnItem: css({
|
||||
'a::before': {
|
||||
content: '""',
|
||||
},
|
||||
svg: {
|
||||
color: theme.colors.warning.text,
|
||||
margin: theme.spacing(0, 1, 0.5, -0.5),
|
||||
},
|
||||
completedStepNumber: css({
|
||||
color: theme.colors.success.main,
|
||||
}),
|
||||
link: css({
|
||||
color: 'inherit',
|
||||
'&::before': {
|
||||
content: 'counter(item) " "',
|
||||
counterIncrement: 'item',
|
||||
},
|
||||
stepText: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.size.md,
|
||||
}),
|
||||
active: css({
|
||||
fontWeight: 500,
|
||||
color: theme.colors.text.maxContrast,
|
||||
'&::before': {
|
||||
fontWeight: 500,
|
||||
},
|
||||
activeStepText: css({
|
||||
color: theme.colors.text.primary,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
divider: css({
|
||||
padding: theme.spacing(2),
|
||||
connector: css({
|
||||
width: '1px',
|
||||
backgroundColor: theme.colors.border.medium,
|
||||
height: theme.spacing(2),
|
||||
marginLeft: theme.spacing(1.5),
|
||||
marginTop: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Button, Text, Stack, Alert, TextLink, Field, Checkbox } from '@grafana/ui';
|
||||
import { Job, useCreateRepositoryJobsMutation } from 'app/api/clients/provisioning';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { JobStatus } from '../Job/JobStatus';
|
||||
import { StepStatus } from '../hooks/useStepStatus';
|
||||
|
||||
import { WizardFormData } from './types';
|
||||
|
||||
export interface SynchronizeStepProps {
|
||||
onStepUpdate: (status: StepStatus, error?: string) => void;
|
||||
requiresMigration: boolean;
|
||||
}
|
||||
|
||||
export function SynchronizeStep({ onStepUpdate, requiresMigration }: SynchronizeStepProps) {
|
||||
const [createJob] = useCreateRepositoryJobsMutation();
|
||||
const { getValues, register } = useFormContext<WizardFormData>();
|
||||
const [history, repoName] = getValues(['migrate.history', 'repositoryName']);
|
||||
const [job, setJob] = useState<Job>();
|
||||
|
||||
const startSynchronization = async () => {
|
||||
if (!repoName) {
|
||||
onStepUpdate('error', t('provisioning.synchronize-step.error-no-repository-name', 'No repository name provided'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onStepUpdate('running');
|
||||
const jobSpec = requiresMigration
|
||||
? {
|
||||
migrate: {
|
||||
history,
|
||||
},
|
||||
}
|
||||
: {
|
||||
pull: {
|
||||
incremental: false, // will queue a full resync job
|
||||
},
|
||||
};
|
||||
|
||||
const response = await createJob({
|
||||
name: repoName,
|
||||
jobSpec,
|
||||
}).unwrap();
|
||||
|
||||
if (!response?.metadata?.name) {
|
||||
return onStepUpdate('error', t('provisioning.synchronize-step.error-no-job-id', 'Failed to start job'));
|
||||
}
|
||||
setJob(response);
|
||||
} catch (error) {
|
||||
onStepUpdate('error', t('provisioning.synchronize-step.error-starting-job', 'Error starting job'));
|
||||
}
|
||||
};
|
||||
|
||||
if (job) {
|
||||
return (
|
||||
<JobStatus
|
||||
watch={job}
|
||||
onStatusChange={(success) => {
|
||||
if (success) {
|
||||
onStepUpdate('success');
|
||||
} else {
|
||||
onStepUpdate('error', t('provisioning.synchronize-step.error-job-failed', 'Job failed'));
|
||||
}
|
||||
}}
|
||||
onRunningChange={(isRunning) => {
|
||||
if (isRunning) {
|
||||
onStepUpdate('running');
|
||||
}
|
||||
}}
|
||||
onErrorChange={(error) => {
|
||||
if (error) {
|
||||
onStepUpdate('error', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={3} alignItems="flex-start">
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="provisioning.wizard.sync-description">
|
||||
Sync resources with external storage. After this one-time step, all future updates will be automatically saved
|
||||
to the repository and provisioned back into the instance.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Alert
|
||||
title={t(
|
||||
'provisioning.wizard.alert-title',
|
||||
'Important: No data or configuration will be lost, but dashboards will be temporarily unavailable for a few minutes.'
|
||||
)}
|
||||
severity={'info'}
|
||||
>
|
||||
<ul style={{ marginLeft: '16px' }}>
|
||||
<li>
|
||||
<Trans i18nKey="provisioning.wizard.alert-point-1">
|
||||
Resources won't be able to be created, edited, or deleted during this process. In the last step, they will
|
||||
disappear.
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans i18nKey="provisioning.wizard.alert-point-2">
|
||||
Once provisioning is complete, resources will reappear and be managed through external storage.
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans i18nKey="provisioning.wizard.alert-point-3">
|
||||
The duration of this process depends on the number of resources involved.
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans i18nKey="provisioning.wizard.alert-point-4">
|
||||
Enterprise instance administrators can display an announcement banner to users. See{' '}
|
||||
<TextLink external href="https://grafana.com/docs/grafana/latest/administration/announcement-banner/">
|
||||
this guide
|
||||
</TextLink>{' '}
|
||||
for step-by-step instructions.
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
{requiresMigration && (
|
||||
<>
|
||||
<Text element="h3">
|
||||
<Trans i18nKey="provisioning.synchronize-step.synchronization-options">Synchronization options</Trans>
|
||||
</Text>
|
||||
<Field>
|
||||
<Checkbox
|
||||
{...register('migrate.history')}
|
||||
label={t('provisioning.wizard.sync-option-history', 'History')}
|
||||
description={
|
||||
<Trans i18nKey="provisioning.synchronize-step.synchronization-description">
|
||||
Include commits for each historical value
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="primary" onClick={startSynchronization}>
|
||||
<Trans i18nKey="provisioning.wizard.button-start">Begin synchronization</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useDeleteRepositoryMutation,
|
||||
useGetFrontendSettingsQuery,
|
||||
} from 'app/api/clients/provisioning';
|
||||
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { PROVISIONING_URL } from '../constants';
|
||||
@@ -21,10 +22,9 @@ import { dataToSpec } from '../utils/data';
|
||||
import { BootstrapStep } from './BootstrapStep';
|
||||
import { ConnectStep } from './ConnectStep';
|
||||
import { FinishStep } from './FinishStep';
|
||||
import { MigrateStep } from './MigrateStep';
|
||||
import { PullStep } from './PullStep';
|
||||
import { RequestErrorAlert } from './RequestErrorAlert';
|
||||
import { Step, Stepper } from './Stepper';
|
||||
import { SynchronizeStep } from './SynchronizeStep';
|
||||
import { WizardFormData, WizardStep } from './types';
|
||||
|
||||
const appEvents = getAppEvents();
|
||||
@@ -54,7 +54,13 @@ export function WizardContent({
|
||||
stepSuccess,
|
||||
settingsData,
|
||||
}: WizardContentProps) {
|
||||
const { watch, setValue, getValues, trigger } = useFormContext<WizardFormData>();
|
||||
const {
|
||||
watch,
|
||||
setValue,
|
||||
getValues,
|
||||
trigger,
|
||||
formState: { isDirty },
|
||||
} = useFormContext<WizardFormData>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const repoName = watch('repositoryName');
|
||||
@@ -96,9 +102,8 @@ export function WizardContent({
|
||||
await deleteRepository({ name });
|
||||
// Wait before redirecting to ensure deletion is processed
|
||||
setTimeout(() => {
|
||||
settingsQuery.refetch();
|
||||
navigate(PROVISIONING_URL);
|
||||
}, 1500);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
@@ -169,75 +174,85 @@ export function WizardContent({
|
||||
}, [saveRequest, setValue, handleStatusChange]);
|
||||
|
||||
const isNextButtonDisabled = () => {
|
||||
if (activeStep === 'synchronize') {
|
||||
return stepStatus !== 'success';
|
||||
}
|
||||
return isSubmitting || isCancelling || stepStatus === 'running' || stepStatus === 'error';
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form}>
|
||||
<Stepper
|
||||
steps={availableSteps}
|
||||
activeStep={activeStep}
|
||||
visitedSteps={completedSteps}
|
||||
validationResults={{
|
||||
connection: { valid: true },
|
||||
bootstrap: { valid: true },
|
||||
migrate: { valid: true },
|
||||
pull: { valid: true },
|
||||
finish: { valid: true },
|
||||
}}
|
||||
/>
|
||||
<Box marginBottom={2}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text element="h2">
|
||||
{currentStepIndex + 1}. {currentStep?.title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack gap={6} direction="row" alignItems="flex-start">
|
||||
<Stepper steps={availableSteps} activeStep={activeStep} visitedSteps={completedSteps} />
|
||||
<div className={styles.divider} />
|
||||
<form className={styles.form}>
|
||||
<FormPrompt onDiscard={handleCancel} confirmRedirect={isDirty && activeStep !== 'finish' && !isCancelling} />
|
||||
<Stack direction="column">
|
||||
<Box marginBottom={2}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text element="h2">
|
||||
{currentStepIndex + 1}. {currentStep?.title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<RequestErrorAlert
|
||||
request={saveRequest}
|
||||
title={t('provisioning.wizard-content.title-repository-verification-failed', 'Repository verification failed')}
|
||||
/>
|
||||
|
||||
<div className={styles.content}>
|
||||
{activeStep === 'connection' && <ConnectStep />}
|
||||
{activeStep === 'bootstrap' && (
|
||||
<BootstrapStep
|
||||
onOptionSelect={onOptionSelect}
|
||||
onStepUpdate={handleStepUpdate}
|
||||
settingsData={settingsData}
|
||||
repoName={repoName!}
|
||||
<RequestErrorAlert
|
||||
request={saveRequest}
|
||||
title={t(
|
||||
'provisioning.wizard-content.title-repository-verification-failed',
|
||||
'Repository verification failed'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{activeStep === 'migrate' && requiresMigration && <MigrateStep onStepUpdate={handleStepUpdate} />}
|
||||
{activeStep === 'pull' && !requiresMigration && <PullStep onStepUpdate={handleStepUpdate} />}
|
||||
{activeStep === 'finish' && <FinishStep />}
|
||||
</div>
|
||||
|
||||
{stepError && <Alert severity="error" title={stepError} />}
|
||||
<div className={styles.content}>
|
||||
{activeStep === 'connection' && <ConnectStep />}
|
||||
{activeStep === 'bootstrap' && (
|
||||
<BootstrapStep
|
||||
onOptionSelect={onOptionSelect}
|
||||
onStepUpdate={handleStepUpdate}
|
||||
settingsData={settingsData}
|
||||
repoName={repoName ?? ''}
|
||||
/>
|
||||
)}
|
||||
{activeStep === 'synchronize' && (
|
||||
<SynchronizeStep onStepUpdate={handleStepUpdate} requiresMigration={requiresMigration} />
|
||||
)}
|
||||
{activeStep === 'finish' && <FinishStep />}
|
||||
</div>
|
||||
|
||||
<Stack gap={2} justifyContent="flex-end">
|
||||
<Button
|
||||
variant={stepStatus === 'error' ? 'primary' : 'secondary'}
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting || isCancelling}
|
||||
>
|
||||
{isCancelling
|
||||
? t('provisioning.wizard-content.button-cancelling', 'Cancelling...')
|
||||
: t('provisioning.wizard-content.button-cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleNextWithSubmit} disabled={isNextButtonDisabled()}>
|
||||
{isSubmitting
|
||||
? t('provisioning.wizard-content.button-submitting', 'Submitting...')
|
||||
: getNextButtonText(activeStep)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
{stepError && <Alert severity="error" title={stepError} />}
|
||||
|
||||
<Stack gap={2} justifyContent="flex-end">
|
||||
<Button
|
||||
variant={stepStatus === 'error' ? 'primary' : 'secondary'}
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting || isCancelling}
|
||||
>
|
||||
{isCancelling
|
||||
? t('provisioning.wizard-content.button-cancelling', 'Cancelling...')
|
||||
: t('provisioning.wizard-content.button-cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleNextWithSubmit} disabled={isNextButtonDisabled()}>
|
||||
{isSubmitting
|
||||
? t('provisioning.wizard-content.button-submitting', 'Submitting...')
|
||||
: getNextButtonText(activeStep)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
form: css({
|
||||
maxWidth: '900px',
|
||||
flexGrow: 1,
|
||||
}),
|
||||
divider: css({
|
||||
width: 1,
|
||||
alignSelf: 'stretch',
|
||||
backgroundColor: theme.colors.border.weak,
|
||||
// align with the button row
|
||||
marginBottom: theme.spacing(13),
|
||||
}),
|
||||
content: css({
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
|
||||
@@ -9,22 +9,20 @@ import { ModeOption, SystemState } from './types';
|
||||
const migrateInstance: ModeOption = {
|
||||
target: 'instance',
|
||||
operation: 'migrate',
|
||||
label: 'Migrate instance to repository',
|
||||
description: 'Save all Grafana resources in the repository',
|
||||
};
|
||||
|
||||
const pullInstance: ModeOption = {
|
||||
target: 'instance',
|
||||
operation: 'pull',
|
||||
label: 'Pull from repository to instance',
|
||||
description: 'Pull resources from the repository into this Grafana instance',
|
||||
label: 'Sync all resources with external storage',
|
||||
description:
|
||||
'Resources will be synced with external storage and provisioned into this instance. Existing Grafana resources will be migrated and merged if needed. After setup, all new resources and changes will be saved to external storage and automatically provisioned back into the instance.',
|
||||
subtitle: 'Use this option if you want to sync and manage your entire Grafana instance through external storage.',
|
||||
};
|
||||
|
||||
const pullFolder: ModeOption = {
|
||||
target: 'folder',
|
||||
operation: 'pull',
|
||||
label: 'Pull from repository to folder',
|
||||
description: 'Pull repository resources into a repository-managed Grafana folder',
|
||||
label: 'Sync external storage to a new Grafana folder',
|
||||
description:
|
||||
'After setup, a new Grafana folder will be created and synced with external storage. If any resources are present in external storage, they will be provisioned to this new folder. All new resources created in this folder will be stored and versioned in external storage.',
|
||||
subtitle:
|
||||
'Use this option to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 folders.',
|
||||
};
|
||||
|
||||
function getDisabledReason(action: ModeOption, resourceCount: number, folderConnected?: boolean) {
|
||||
@@ -91,16 +89,11 @@ export function getState(
|
||||
if (settings?.legacyStorage) {
|
||||
const disabledReason = 'Instance must be migrated first';
|
||||
state.actions = [migrateInstance];
|
||||
state.disabled = [
|
||||
{ ...pullInstance, disabledReason },
|
||||
{ ...pullFolder, disabledReason },
|
||||
];
|
||||
state.disabled = [{ ...pullFolder, disabledReason }];
|
||||
return state;
|
||||
}
|
||||
|
||||
const actionsToEvaluate = resourceCount
|
||||
? [pullFolder, pullInstance, migrateInstance] // recommend pull when resources already exist
|
||||
: [migrateInstance, pullInstance, pullFolder];
|
||||
const actionsToEvaluate = [migrateInstance, pullFolder];
|
||||
actionsToEvaluate.forEach((action) => {
|
||||
const reason = getDisabledReason(action, resourceCount, folderConnected);
|
||||
if (reason) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { SyncOptions } from 'app/api/clients/provisioning';
|
||||
import { RepositorySpec, SyncOptions } from 'app/api/clients/provisioning';
|
||||
|
||||
import { RepositoryFormData } from '../types';
|
||||
|
||||
export type WizardStep = 'connection' | 'bootstrap' | 'migrate' | 'pull' | 'finish';
|
||||
export type WizardStep = 'connection' | 'bootstrap' | 'finish' | 'synchronize';
|
||||
|
||||
export type RepoType = RepositorySpec['type'];
|
||||
|
||||
export interface MigrateFormData {
|
||||
history: boolean;
|
||||
@@ -15,11 +17,6 @@ export interface WizardFormData {
|
||||
repositoryName?: string;
|
||||
}
|
||||
|
||||
export type ValidationResult = {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
export type Target = SyncOptions['target'];
|
||||
export type Operation = 'pull' | 'migrate';
|
||||
|
||||
@@ -29,6 +26,7 @@ export interface ModeOption {
|
||||
label: string;
|
||||
description: string;
|
||||
disabledReason?: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export interface SystemState {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const PROVISIONING_URL = '/admin/provisioning';
|
||||
export const CONNECT_URL = `${PROVISIONING_URL}/connect`;
|
||||
export const MIGRATE_URL = `${PROVISIONING_URL}/migrate`;
|
||||
export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { GitHubRepositoryConfig, LocalRepositoryConfig, RepositorySpec } from '../../api/clients/provisioning';
|
||||
|
||||
export type RepositoryFormData = Omit<RepositorySpec, 'github' | 'local'> &
|
||||
export type RepositoryFormData = Omit<RepositorySpec, 'github' | 'local' | 'workflows'> &
|
||||
GitHubRepositoryConfig &
|
||||
LocalRepositoryConfig;
|
||||
LocalRepositoryConfig & {
|
||||
readOnly: boolean;
|
||||
prWorkflow: boolean;
|
||||
};
|
||||
|
||||
// Added to DashboardDTO to help editor
|
||||
export interface ProvisioningPreview {
|
||||
@@ -11,7 +14,7 @@ export interface ProvisioningPreview {
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
export type WorkflowOption = 'branch' | 'write';
|
||||
export type WorkflowOption = RepositorySpec['workflows'][number];
|
||||
|
||||
export type HistoryItem = {
|
||||
ref: string;
|
||||
|
||||
@@ -2,12 +2,25 @@ import { RepositorySpec } from 'app/api/clients/provisioning';
|
||||
|
||||
import { RepositoryFormData } from '../types';
|
||||
|
||||
const getWorkflows = (data: RepositoryFormData): RepositorySpec['workflows'] => {
|
||||
if (data.readOnly) {
|
||||
return [];
|
||||
}
|
||||
const workflows: RepositorySpec['workflows'] = ['write'];
|
||||
|
||||
if (!data.prWorkflow) {
|
||||
return workflows;
|
||||
}
|
||||
|
||||
return [...workflows, 'branch'];
|
||||
};
|
||||
|
||||
export const dataToSpec = (data: RepositoryFormData): RepositorySpec => {
|
||||
const spec: RepositorySpec = {
|
||||
type: data.type,
|
||||
sync: data.sync,
|
||||
title: data.title || '',
|
||||
workflows: data.workflows,
|
||||
workflows: getWorkflows(data),
|
||||
};
|
||||
switch (data.type) {
|
||||
case 'github':
|
||||
@@ -39,5 +52,7 @@ export const specToData = (spec: RepositorySpec): RepositoryFormData => {
|
||||
branch: spec.github?.branch || '',
|
||||
url: spec.github?.url || '',
|
||||
generateDashboardPreviews: spec.github?.generateDashboardPreviews || false,
|
||||
readOnly: !spec.workflows.length,
|
||||
prWorkflow: spec.workflows.includes('write'),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
|
||||
),
|
||||
},
|
||||
{
|
||||
path: CONNECT_URL,
|
||||
path: `${CONNECT_URL}/:type`,
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "ProvisioningWizardPage"*/ 'app/features/provisioning/Wizard/ConnectPage')
|
||||
),
|
||||
|
||||
@@ -3502,7 +3502,7 @@
|
||||
},
|
||||
"provisioned-resource-delete-modal": {
|
||||
"file-path": "File path:",
|
||||
"managed-by-version-control": "This {type} is managed by version control and cannot be deleted. To remove it, delete it from the repository and synchronise to apply the changes.",
|
||||
"managed-by-version-control": "This resource is managed by version control and cannot be deleted. To remove it, delete it from the repository and synchronise to apply the changes.",
|
||||
"ok": "OK",
|
||||
"title-cannot-delete-provisioned-resource": "Cannot delete provisioned resource"
|
||||
},
|
||||
@@ -6352,14 +6352,14 @@
|
||||
"description-clear-repository-connection": "Add a clear name for this repository connection",
|
||||
"empty": "Empty",
|
||||
"error-field-required": "This field is required.",
|
||||
"ext-storage": "External storage",
|
||||
"files-count_one": "{{count}} files",
|
||||
"files-count_other": "{{count}} files",
|
||||
"grafana": "Grafana",
|
||||
"grafana": "Grafana instance",
|
||||
"include-history": "Include history",
|
||||
"label-display-name": "Display name",
|
||||
"label-migrate-options": "Migrate options",
|
||||
"placeholder-my-repository-connection": "My repository connection",
|
||||
"repository": "Repository",
|
||||
"resources-will-be-added": "The {{count}} resources in grafana will be added to the repository. Grafana will then include both the current resources and anything from the repository when done.",
|
||||
"text-loading-resource-information": "Loading resource information...",
|
||||
"title-files-exist-in-the-target": "Files exist in the target",
|
||||
@@ -6374,13 +6374,11 @@
|
||||
"alert-repository-settings-updated": "Repository settings updated",
|
||||
"button-save": "Save",
|
||||
"button-saving": "Saving...",
|
||||
"description-branch": "Create a branch (and pull request) for changes",
|
||||
"description-enabled": "Once automatic pulling is enabled, the target cannot be changed.",
|
||||
"description-path": "Path to a subdirectory in the Git repository",
|
||||
"description-read-only": "Resources can't be modified through Grafana.",
|
||||
"description-repository-url": "Enter the GitHub repository URL",
|
||||
"description-title": "A human-readable name for the config",
|
||||
"description-workflows-makes-repository": "No workflows makes the repository read only",
|
||||
"description-write": "Allow writing updates to the remote repository",
|
||||
"error-required": "This field is required.",
|
||||
"error-save-repository": "Failed to save repository settings",
|
||||
"error-valid-github-url": "Please enter a valid GitHub repository URL",
|
||||
@@ -6391,17 +6389,15 @@
|
||||
"label-interval-seconds": "Interval (seconds)",
|
||||
"label-local-path": "Local path",
|
||||
"label-path": "Path",
|
||||
"label-pr-workflow": "Enable pull request option when saving",
|
||||
"label-repository-type": "Repository type",
|
||||
"label-repository-url": "Repository URL",
|
||||
"label-target": "Target",
|
||||
"label-title": "Title",
|
||||
"label-workflows": "Workflows",
|
||||
"option-branch": "Branch",
|
||||
"option-entire-instance": "Entire instance",
|
||||
"option-github": "GitHub",
|
||||
"option-local": "Local",
|
||||
"option-managed-folder": "Managed folder",
|
||||
"option-write": "Write",
|
||||
"placeholder-branch": "main",
|
||||
"placeholder-github-token": "ghp_yourTokenHere1234567890abcdEFGHijklMNOP",
|
||||
"placeholder-github-url": "https://github.com/username/repo-name",
|
||||
@@ -6409,7 +6405,6 @@
|
||||
"placeholder-local-path": "/path/to/repo",
|
||||
"placeholder-my-config": "My config",
|
||||
"placeholder-path": "grafana/",
|
||||
"placeholder-readonly-repository": "Readonly repository",
|
||||
"placeholder-select-repository-type": "Select repository type"
|
||||
},
|
||||
"config-form-github-collapse": {
|
||||
@@ -6427,30 +6422,28 @@
|
||||
"title-webhook-will-be-created": "Webhook will be created"
|
||||
},
|
||||
"connect-repository-button": {
|
||||
"configure": "Configure",
|
||||
"configure-file": "Configure file provisioning",
|
||||
"configure-git-sync": "Configure GitSync",
|
||||
"configure-git-sync": "Configure Git Sync",
|
||||
"repository-limit-info-alert": "Repository limit reached ({{count}})"
|
||||
},
|
||||
"connect-step": {
|
||||
"description-choose-storage-resources": "Choose the type of storage for your resources",
|
||||
"description-github-path": "Path to a subdirectory in the Git repository",
|
||||
"description-branch": "Branch to use for the GitHub repository",
|
||||
"description-github-path": "This is the path to a subdirectory in your GitHub repository where dashboards will be stored and provisioned from",
|
||||
"description-paste-your-git-hub-personal-access-token": "Paste your GitHub personal access token",
|
||||
"description-repository-url": "Paste the URL of your GitHub repository",
|
||||
"error-field-required": "This field is required.",
|
||||
"error-invalid-github-url": "Please enter a valid GitHub repository URL",
|
||||
"label-access-token": "Enter your access token",
|
||||
"label-branch": "Branch",
|
||||
"label-access-token": "GitHub access token",
|
||||
"label-branch": "Branch name",
|
||||
"label-local-path": "Local path",
|
||||
"label-path": "Path",
|
||||
"label-repository-url": "Enter your Repository URL",
|
||||
"label-storage-type": "Storage type",
|
||||
"label-path": "Path to subdirectory in repository",
|
||||
"label-repository-url": "GitHub repository URL",
|
||||
"placeholder-branch": "main",
|
||||
"placeholder-github-path": "grafana/",
|
||||
"placeholder-github-token": "github_pat_yourTokenHere1234567890abcdEFGHijklMNOP",
|
||||
"placeholder-github-url": "https://github.com/username/repo",
|
||||
"placeholder-local-path": "/path/to/repo",
|
||||
"storage-type-github": "GitHub",
|
||||
"storage-type-local": "Local"
|
||||
"placeholder-local-path": "/path/to/repo"
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-delete": "Delete",
|
||||
@@ -6488,7 +6481,6 @@
|
||||
},
|
||||
"manage-dashboards-provision-updates-automatically": "Manage dashboards as code in GitHub and provision updates automatically",
|
||||
"manage-your-dashboards-with-remote-provisioning": "Get started with GitSync",
|
||||
"migrate-existing-dashboards-storage-provisioning": "Migrate existing dashboards to GitHub for provisioning",
|
||||
"store-dashboards-in-version-controlled-storage": "Store dashboards in version-controlled storage for better organization and history tracking"
|
||||
},
|
||||
"file-history-page": {
|
||||
@@ -6506,19 +6498,19 @@
|
||||
"placeholder-search": "Search"
|
||||
},
|
||||
"finish-step": {
|
||||
"description-dashboard-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-enable-webhooks": "Enable webhooks to automatically notify Grafana when a change occurs in the repository. This will allow Grafana to pull changes as soon as they are made.",
|
||||
"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-image-rendering": "Requires image rendering. <2>Set up image rendering</2>",
|
||||
"description-often-shall-instance-updates-git-hub": "How often shall the instance pull updates from GitHub?",
|
||||
"description-select-workflows-allowed-within-repository": "Select the workflows that are allowed within this repository",
|
||||
"error-field-required": "This field is required.",
|
||||
"label-enable-dashboard-previews": "Enable dashboard previews in pull requests",
|
||||
"label-enable-webhooks": "Enable webhooks on changes",
|
||||
"description-pr-enable-description": "Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.",
|
||||
"description-read-only": "Resources can't be modified through Grafana.",
|
||||
"description-webhooks-enable": "Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.",
|
||||
"label-enable-previews": "Enable dashboard previews in pull requests",
|
||||
"label-pr-workflow": "Enable pull request option when saving",
|
||||
"label-read-only": "Read only",
|
||||
"label-update-instance-interval-seconds": "Update instance interval (seconds)",
|
||||
"label-workflows": "Workflows",
|
||||
"link-setup-image-rendering": "Set up image rendering",
|
||||
"placeholder": "60",
|
||||
"placeholder-readonly-repository": "Read-only repository",
|
||||
"text-requires-image-rendering": "(Requires image rendering."
|
||||
"text-setup-later": "You can always set this up later",
|
||||
"title-enhance-github": "Enhance your GitHub experience"
|
||||
},
|
||||
"folder-repository-list": {
|
||||
"no-results-matching-your-query": "No results matching your query",
|
||||
@@ -6576,17 +6568,6 @@
|
||||
},
|
||||
"summary": "Summary"
|
||||
},
|
||||
"job-step": {
|
||||
"error-failed-to-start": "Failed to start operation",
|
||||
"error-invalid-response": "Invalid response from operation",
|
||||
"error-job-failed": "Job failed"
|
||||
},
|
||||
"migrate-step": {
|
||||
"description-migrating-dashboards": "Migrating all dashboards from this instance to your repository, including their identifiers and complete history. After this one-time migration, all future updates will be automatically saved to the repository."
|
||||
},
|
||||
"pull-step": {
|
||||
"description-pulling-content": "Pulling all content from your repository to this Grafana instance. This ensures your dashboards and other resources are synchronized with the repository."
|
||||
},
|
||||
"recent-jobs": {
|
||||
"active-jobs": "active jobs",
|
||||
"column-action": "Action",
|
||||
@@ -6615,8 +6596,7 @@
|
||||
"title-repository-is-unhealthy": "Repository is unhealthy"
|
||||
},
|
||||
"repository-link": {
|
||||
"grafana-repository": "Grafana and your repository are now in sync.",
|
||||
"view-folder": "View folder",
|
||||
"grafana-repository-synced": "Your resources are now in your external storage and provisioned into your instance. From now on, your instance and the external storage will be synchronized.",
|
||||
"view-repository": "View repository"
|
||||
},
|
||||
"repository-overview": {
|
||||
@@ -6695,30 +6675,37 @@
|
||||
"title-pull-not-enabled": "Pull is not enabled",
|
||||
"tooltip-unhealthy-repository": "Unable to pull an unhealthy repository"
|
||||
},
|
||||
"synchronize-step": {
|
||||
"error-job-failed": "Job failed",
|
||||
"error-no-job-id": "Failed to start job",
|
||||
"error-no-repository-name": "No repository name provided",
|
||||
"error-starting-job": "Error starting job",
|
||||
"synchronization-description": "Include commits for each historical value",
|
||||
"synchronization-options": "Synchronization options"
|
||||
},
|
||||
"token-permissions-info": {
|
||||
"access": "Access",
|
||||
"contents": "Contents",
|
||||
"github-instructions": "Go to <2>GitHub Personal Access Tokens</2>. Make sure to include these permissions under <4>Repository</4>:",
|
||||
"metadata": "Metadata",
|
||||
"permission": "Permission",
|
||||
"pull-requests": "Pull requests",
|
||||
"read-and-write": "Read and write",
|
||||
"readonly": "Read-only",
|
||||
"webhooks": "Webhooks"
|
||||
"and-click": "and click",
|
||||
"go-to": "Go to",
|
||||
"make-sure": "Make sure to include these permissions"
|
||||
},
|
||||
"wizard": {
|
||||
"button-finish": "Finish",
|
||||
"button-next": "Next",
|
||||
"button-start": "Start",
|
||||
"step-bootstrap": "Bootstrap",
|
||||
"alert-point-1": "Resources won't be able to be created, edited, or deleted during this process. In the last step, they will disappear.",
|
||||
"alert-point-2": "Once provisioning is complete, resources will reappear and be managed through external storage.",
|
||||
"alert-point-3": "The duration of this process depends on the number of resources involved.",
|
||||
"alert-point-4": "Enterprise instance administrators can display an announcement banner to users. See <2>this guide</2> for step-by-step instructions.",
|
||||
"alert-title": "Important: No data or configuration will be lost, but dashboards will be temporarily unavailable for a few minutes.",
|
||||
"button-next": "Finish",
|
||||
"button-start": "Begin synchronization",
|
||||
"step-bootstrap": "Choose what to synchronize",
|
||||
"step-connect": "Connect",
|
||||
"step-finish": "Finish",
|
||||
"step-resources": "Resources",
|
||||
"title-bootstrap": "Bootstrap repository",
|
||||
"step-finish": "Choose additional settings",
|
||||
"step-synchronize": "Synchronize",
|
||||
"sync-description": "Sync resources with external storage. After this one-time step, all future updates will be automatically saved to the repository and provisioned back into the instance.",
|
||||
"sync-option-history": "History",
|
||||
"title-bootstrap": "Choose what to synchronize",
|
||||
"title-connect": "Connect to external storage",
|
||||
"title-finish": "Finish setup",
|
||||
"title-migrate": "Migrate resources",
|
||||
"title-pull": "Pull resources"
|
||||
"title-finish": "Choose additional settings",
|
||||
"title-synchronize": "Synchronize with external storage"
|
||||
},
|
||||
"wizard-content": {
|
||||
"button-cancel": "Cancel",
|
||||
|
||||
Reference in New Issue
Block a user