From b58729dd65d11f8f44dcfb13cf1ca29af24445d6 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Thu, 10 Apr 2025 09:55:31 +0300 Subject: [PATCH] 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 --- .../grafana-ui/src/components/Forms/Field.tsx | 24 +-- .../ProvisionedResourceDeleteModal.tsx | 10 +- .../provisioning/Config/ConfigForm.tsx | 82 ++++---- .../GettingStarted/EnhancedFeatures.tsx | 47 +++-- .../GettingStarted/FeaturesList.tsx | 5 - .../GettingStarted/GettingStarted.tsx | 6 +- public/app/features/provisioning/HomePage.tsx | 4 +- .../features/provisioning/Job/JobContent.tsx | 13 +- .../Repository/RepositoryLink.tsx | 15 +- .../Shared/ConnectRepositoryButton.tsx | 56 +++++- .../provisioning/Shared/ProgressBar.tsx | 2 +- ...rRepositoryList.tsx => RepositoryList.tsx} | 4 +- .../provisioning/Shared/StatusBadge.tsx | 2 +- .../Shared/TokenPermissionsInfo.tsx | 105 +++++------ .../provisioning/Wizard/BootstrapStep.tsx | 30 +-- .../provisioning/Wizard/ConnectPage.tsx | 13 +- .../provisioning/Wizard/ConnectStep.tsx | 58 +----- .../provisioning/Wizard/FinishStep.tsx | 177 ++++++++---------- .../features/provisioning/Wizard/JobStep.tsx | 84 --------- .../provisioning/Wizard/MigrateStep.tsx | 43 ----- .../Wizard/ProvisioningWizard.tsx | 67 +++---- .../features/provisioning/Wizard/PullStep.tsx | 37 ---- .../features/provisioning/Wizard/Stepper.tsx | 122 ++++++------ .../provisioning/Wizard/SynchronizeStep.tsx | 150 +++++++++++++++ .../provisioning/Wizard/WizardContent.tsx | 135 +++++++------ .../features/provisioning/Wizard/actions.ts | 29 ++- .../app/features/provisioning/Wizard/types.ts | 12 +- public/app/features/provisioning/constants.ts | 1 - public/app/features/provisioning/types.ts | 9 +- .../app/features/provisioning/utils/data.ts | 17 +- .../app/features/provisioning/utils/routes.ts | 2 +- public/locales/en-US/grafana.json | 115 +++++------- 32 files changed, 691 insertions(+), 785 deletions(-) rename public/app/features/provisioning/Shared/{FolderRepositoryList.tsx => RepositoryList.tsx} (92%) delete mode 100644 public/app/features/provisioning/Wizard/JobStep.tsx delete mode 100644 public/app/features/provisioning/Wizard/MigrateStep.tsx delete mode 100644 public/app/features/provisioning/Wizard/PullStep.tsx create mode 100644 public/app/features/provisioning/Wizard/SynchronizeStep.tsx diff --git a/packages/grafana-ui/src/components/Forms/Field.tsx b/packages/grafana-ui/src/components/Forms/Field.tsx index 461159963c6..10db5c83bfc 100644 --- a/packages/grafana-ui/src/components/Forms/Field.tsx +++ b/packages/grafana-ui/src/components/Forms/Field.tsx @@ -15,8 +15,6 @@ export interface FieldProps extends HTMLAttributes { 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( ( { label, - useLabel, description, horizontal, invalid, @@ -66,25 +63,14 @@ export const Field = React.forwardRef( const styles = useStyles2(getFieldStyles); const inputId = htmlFor ?? getChildId(children); - let labelElement: React.ReactNode; - if (typeof label === 'string') { - labelElement = ( + const labelElement = + typeof label === 'string' ? ( + ) : ( + label ); - } else if (useLabel) { - labelElement = ( - - ); - } else { - labelElement = label; - } const childProps = deleteUndefinedProps({ invalid, disabled, loading }); return ( diff --git a/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx b/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx index a8e63355c19..e3dd6484d79 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx @@ -13,7 +13,6 @@ export interface Props { } export function ProvisionedResourceDeleteModal({ onDismiss, resource }: Props) { - const type = isDashboard(resource) ? 'dashboard' : 'folder'; return ( <>

- - 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. + + 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.

{isDashboard(resource) && ( diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 4011eb4e54b..522a6c7f443 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -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> { - const opts: Array> = [ - { - 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) { )} - - ( - { - onChange(val.map((v) => v.value)); - }} - {...field} - /> + + { + 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." )} /> + + + 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. + + } + /> + {type === 'github' && ( } diff --git a/public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx b/public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx index d6d40fcf8b6..9937b4e94e2 100644 --- a/public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx +++ b/public/app/features/provisioning/GettingStarted/EnhancedFeatures.tsx @@ -16,7 +16,7 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub const style = useStyles2(getStyles); return ( - + Enhance your GitHub experience @@ -28,7 +28,7 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub - + @@ -44,16 +44,28 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub - {!hasPublicAccess && ( - - Set up public webhooks - - )} + + Set up public webhooks +
- + Visual previews in pull requests with image rendering @@ -65,16 +77,15 @@ export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPub - {hasImageRenderer && ( - - Set up image rendering - - )} + + Set up image rendering + diff --git a/public/app/features/provisioning/GettingStarted/FeaturesList.tsx b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx index 17eeedf1270..bec48f7dc85 100644 --- a/public/app/features/provisioning/GettingStarted/FeaturesList.tsx +++ b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx @@ -34,11 +34,6 @@ export const FeaturesList = ({ repos, hasRequiredFeatures, onSetupFeatures }: Fe Store dashboards in version-controlled storage for better organization and history tracking -
  • - - Migrate existing dashboards to GitHub for provisioning - -
  • {!hasRequiredFeatures ? ( diff --git a/public/app/features/provisioning/GettingStarted/GettingStarted.tsx b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx index 881f7e02590..ab4de428af7 100644 --- a/public/app/features/provisioning/GettingStarted/GettingStarted.tsx +++ b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx @@ -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(null); @@ -172,7 +172,7 @@ export default function GettingStarted({ items }: Props) {
    - {(!hasPublicAccess || !hasImageRenderer) && ( + {(!hasPublicAccess || !hasImageRenderer) && hasItems && ( { switch (activeTab) { case TabSelection.Repositories: - return ; + return ; case TabSelection.GettingStarted: return ; default: diff --git a/public/app/features/provisioning/Job/JobContent.tsx b/public/app/features/provisioning/Job/JobContent.tsx index bca97a03c74..c5f0ef5b76d 100644 --- a/public/app/features/provisioning/Job/JobContent.tsx +++ b/public/app/features/provisioning/Job/JobContent.tsx @@ -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) { {getStatusDisplay()} - - - - - + {state && !['success', 'error'].includes(state) && ( + + + + )} {isFinishedJob && summary && ( @@ -66,7 +67,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) { )} {state === 'success' ? ( - + ) : (
    {JSON.stringify(job, null, 2)}
    diff --git a/public/app/features/provisioning/Repository/RepositoryLink.tsx b/public/app/features/provisioning/Repository/RepositoryLink.tsx index bd57dae948e..3296dde6b7d 100644 --- a/public/app/features/provisioning/Repository/RepositoryLink.tsx +++ b/public/app/features/provisioning/Repository/RepositoryLink.tsx @@ -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 ( - - Grafana and your repository are now in sync. + + 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 folder - + ); diff --git a/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx b/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx index e1df0c2e83c..71d6f332fa8 100644 --- a/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx +++ b/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx @@ -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 ( + + { + navigate(gitURL); + }} + /> + { + navigate(localURL); + }} + /> + + } + > + + + ); + } + return ( - - Configure GitSync + + Configure Git Sync - + Configure file provisioning diff --git a/public/app/features/provisioning/Shared/ProgressBar.tsx b/public/app/features/provisioning/Shared/ProgressBar.tsx index 10529710f33..681321bb0b1 100644 --- a/public/app/features/provisioning/Shared/ProgressBar.tsx +++ b/public/app/features/provisioning/Shared/ProgressBar.tsx @@ -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', }, diff --git a/public/app/features/provisioning/Shared/FolderRepositoryList.tsx b/public/app/features/provisioning/Shared/RepositoryList.tsx similarity index 92% rename from public/app/features/provisioning/Shared/FolderRepositoryList.tsx rename to public/app/features/provisioning/Shared/RepositoryList.tsx index 512d5ef53d1..cf18e479c9e 100644 --- a/public/app/features/provisioning/Shared/FolderRepositoryList.tsx +++ b/public/app/features/provisioning/Shared/RepositoryList.tsx @@ -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} /> - +
    )} diff --git a/public/app/features/provisioning/Shared/StatusBadge.tsx b/public/app/features/provisioning/Shared/StatusBadge.tsx index a69ceafef4e..6513a9ad6bd 100644 --- a/public/app/features/provisioning/Shared/StatusBadge.tsx +++ b/public/app/features/provisioning/Shared/StatusBadge.tsx @@ -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`); }} /> ); diff --git a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx index cbfd06d4a93..c97bea79c18 100644 --- a/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx +++ b/public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx @@ -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 (
    -
    - - Go to{' '} - - GitHub Personal Access Tokens - - . Make sure to include these permissions under Repository: - -
    + {/* GitHub UI is English only, so these strings are not translated */} + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} + + Go to + + GitHub Personal Access Tokens + + and click + "Fine-grained token". + Make sure to include these permissions: + - - - - - - - - - - - - - - - - - - - - - - - -
    - Permission - - Access -
    - Contents - - Read and write -
    - Metadata - - Read-only -
    - Pull requests - - Read and write -
    - Webhooks - - Read and write -
    +
      + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} +
    • + Content: Read and write +
    • + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} +
    • + Metadata: Read only +
    • + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} +
    • + Pull requests: Read and write +
    • + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} +
    • + Webhooks: Read and write +
    • +
    ); } @@ -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), }), }; } diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx index 3d65ece14bc..6fb049a8539 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx @@ -93,24 +93,22 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) - - Grafana + + Grafana instance - - - {state.resourceCount > 0 - ? state.resourceCountString - : t('provisioning.bootstrap-step.empty', 'Empty')} - + + {state.resourceCount > 0 + ? state.resourceCountString + : t('provisioning.bootstrap-step.empty', 'Empty')} - - Repository + + External storage - + {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} > {action.label} - {action.description} + + + {action.description} + {action.subtitle} + + ))} @@ -199,6 +202,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) )} error={errors.repository?.title?.message} invalid={!!errors.repository?.title} + required >
    diff --git a/public/app/features/provisioning/Wizard/ConnectPage.tsx b/public/app/features/provisioning/Wizard/ConnectPage.tsx index 02a43a975be..a696a5049e3 100644 --- a/public/app/features/provisioning/Wizard/ConnectPage.tsx +++ b/public/app/features/provisioning/Wizard/ConnectPage.tsx @@ -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 ( - + ); diff --git a/public/app/features/provisioning/Wizard/ConnectStep.tsx b/public/app/features/provisioning/Wizard/ConnectStep.tsx index 025b728001a..a85321ec151 100644 --- a/public/app/features/provisioning/Wizard/ConnectStep.tsx +++ b/public/app/features/provisioning/Wizard/ConnectStep.tsx @@ -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(); - const type = watch('repository.type'); const [tokenConfigured, setTokenConfigured] = useState(false); - - const typeOptions = useMemo>>( - () => [ - { 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 ( - - { - return ( - { - const repoType = value?.value; - onChange(repoType); - setValue( - 'repository.workflows', - getWorkflowOptions(repoType).map((v) => v.value) - ); - }} - {...field} - /> - ); - }} - /> - - {isGithub && ( <> @@ -137,12 +99,12 @@ export function ConnectStep() { (); - const { errors } = formState; + const { register, watch, setValue } = useFormContext(); - 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(); - 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() { )} - - ( - { - onChange(val.map((v) => v.value)); - }} - {...field} - /> + + { + 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." )} /> - {isGithub && false /* TODO */ && ( - - {/* TODO: Make an option for the switch to control */} - - - )} - {isGithub && ( - - {t( - 'provisioning.finish-step.label-enable-dashboard-previews', - 'Enable dashboard previews in pull requests' - )}{' '} - - {t('provisioning.finish-step.text-requires-image-rendering', '(Requires image rendering.')}{' '} - - {t('provisioning.finish-step.link-setup-image-rendering', 'Set up image rendering')} - - {')'} - - - } - 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} - > - - + <> + + + 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. + + } + /> + + + + + + Enhance your GitHub experience + + + You can always set this up later + + + + + + 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. + {' '} + + + Requires image rendering.{' '} + + Set up image rendering + + + + + } + {...register('repository.generateDashboardPreviews')} + /> + + + )} ); } - -function getStyles(theme: GrafanaTheme2) { - return { - explanation: css({ - color: theme.colors.text.disabled, - fontStyle: 'italic', - }), - explanationLink: css({ - color: theme.colors.text.link, - fontStyle: 'italic', - }), - }; -} diff --git a/public/app/features/provisioning/Wizard/JobStep.tsx b/public/app/features/provisioning/Wizard/JobStep.tsx deleted file mode 100644 index b7dda20597c..00000000000 --- a/public/app/features/provisioning/Wizard/JobStep.tsx +++ /dev/null @@ -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; - children?: ReactNode; -} - -export type { JobStepProps }; - -export function JobStep({ onStepUpdate, description, startJob, children }: JobStepProps) { - const { watch } = useFormContext(); - const repositoryName = watch('repositoryName'); - const stepStatus = useStepStatus({ onStepUpdate }); - const [job, setJob] = useState(); - - // 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 ( - - {description && {description}} - {children} - - {job && ( - { - 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); - } - }} - /> - )} - - ); -} diff --git a/public/app/features/provisioning/Wizard/MigrateStep.tsx b/public/app/features/provisioning/Wizard/MigrateStep.tsx deleted file mode 100644 index 3c950a5ba6f..00000000000 --- a/public/app/features/provisioning/Wizard/MigrateStep.tsx +++ /dev/null @@ -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(); - const history = watch('migrate.history'); - - const startMigration = async (repositoryName: string) => { - const response = await createJob({ - name: repositoryName, - jobSpec: { - migrate: { - history, - }, - }, - }).unwrap(); - - return response; - }; - - return ( - - ); -} diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx index 498f5a4bafd..e5e6d282ccf 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -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('connection'); const [completedSteps, setCompletedSteps] = useState([]); 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({ 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() { 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 ( - - ); -} diff --git a/public/app/features/provisioning/Wizard/Stepper.tsx b/public/app/features/provisioning/Wizard/Stepper.tsx index 1e6bf7108cb..8d157cf0eff 100644 --- a/public/app/features/provisioning/Wizard/Stepper.tsx +++ b/public/app/features/provisioning/Wizard/Stepper.tsx @@ -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 { id: T; name: string; @@ -17,41 +15,35 @@ export interface Props { reportId?: string; visitedSteps?: T[]; steps: Array>; - validationResults: Record; } -export function Stepper({ - validationResults, - visitedSteps = [], - steps, - activeStep = steps[0]?.id, -}: Props) { +export function Stepper({ visitedSteps = [], steps, activeStep = steps[0]?.id }: Props) { const styles = useStyles2(getStyles); - const lastStep = steps[steps.length - 1]; return (
      - {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 ( -
    1. - {successField && } - {warnField && } -
      {step.name}
      - {/* eslint-disable-next-line @grafana/no-untranslated-strings */} - {!isLast &&
      —
      } +
    2. +
      + {isCompleted ? ( +
      + +
      + ) : ( +
      {index + 1}
      + )} +
      {step.name}
      +
      + {!isLast &&
      }
    3. ); })} @@ -62,54 +54,52 @@ export function Stepper({ 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), }), }; }; diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx new file mode 100644 index 00000000000..207910c8eb3 --- /dev/null +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -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(); + const [history, repoName] = getValues(['migrate.history', 'repositoryName']); + const [job, setJob] = useState(); + + 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 ( + { + 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 ( + + + + 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. + + + +
        +
      • + + Resources won't be able to be created, edited, or deleted during this process. In the last step, they will + disappear. + +
      • +
      • + + Once provisioning is complete, resources will reappear and be managed through external storage. + +
      • +
      • + + The duration of this process depends on the number of resources involved. + +
      • +
      • + + Enterprise instance administrators can display an announcement banner to users. See{' '} + + this guide + {' '} + for step-by-step instructions. + +
      • +
      +
      + {requiresMigration && ( + <> + + Synchronization options + + + + Include commits for each historical value + + } + /> + + + )} + + +
      + ); +} diff --git a/public/app/features/provisioning/Wizard/WizardContent.tsx b/public/app/features/provisioning/Wizard/WizardContent.tsx index 79773d721e0..25fe309730e 100644 --- a/public/app/features/provisioning/Wizard/WizardContent.tsx +++ b/public/app/features/provisioning/Wizard/WizardContent.tsx @@ -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(); + const { + watch, + setValue, + getValues, + trigger, + formState: { isDirty }, + } = useFormContext(); 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 ( -
      - - - {/* eslint-disable-next-line @grafana/no-untranslated-strings */} - - {currentStepIndex + 1}. {currentStep?.title} - - + + +
      + + + + + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} + + {currentStepIndex + 1}. {currentStep?.title} + + - - -
      - {activeStep === 'connection' && } - {activeStep === 'bootstrap' && ( - - )} - {activeStep === 'migrate' && requiresMigration && } - {activeStep === 'pull' && !requiresMigration && } - {activeStep === 'finish' && } -
      - {stepError && } +
      + {activeStep === 'connection' && } + {activeStep === 'bootstrap' && ( + + )} + {activeStep === 'synchronize' && ( + + )} + {activeStep === 'finish' && } +
      - - - - - + {stepError && } + + + + + +
      + + ); } 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}`, diff --git a/public/app/features/provisioning/Wizard/actions.ts b/public/app/features/provisioning/Wizard/actions.ts index aa1758c67cf..1add1f04fdb 100644 --- a/public/app/features/provisioning/Wizard/actions.ts +++ b/public/app/features/provisioning/Wizard/actions.ts @@ -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) { diff --git a/public/app/features/provisioning/Wizard/types.ts b/public/app/features/provisioning/Wizard/types.ts index 3a4a8abe342..39abf940ac5 100644 --- a/public/app/features/provisioning/Wizard/types.ts +++ b/public/app/features/provisioning/Wizard/types.ts @@ -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 { diff --git a/public/app/features/provisioning/constants.ts b/public/app/features/provisioning/constants.ts index 9855c025144..74beb4a06f9 100644 --- a/public/app/features/provisioning/constants.ts +++ b/public/app/features/provisioning/constants.ts @@ -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`; diff --git a/public/app/features/provisioning/types.ts b/public/app/features/provisioning/types.ts index e614c9969f9..b3234375c30 100644 --- a/public/app/features/provisioning/types.ts +++ b/public/app/features/provisioning/types.ts @@ -1,8 +1,11 @@ import { GitHubRepositoryConfig, LocalRepositoryConfig, RepositorySpec } from '../../api/clients/provisioning'; -export type RepositoryFormData = Omit & +export type RepositoryFormData = Omit & 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; diff --git a/public/app/features/provisioning/utils/data.ts b/public/app/features/provisioning/utils/data.ts index e3017d7446e..5d22751624e 100644 --- a/public/app/features/provisioning/utils/data.ts +++ b/public/app/features/provisioning/utils/data.ts @@ -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'), }); }; diff --git a/public/app/features/provisioning/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts index 1438fd42b33..c0e828a66a6 100644 --- a/public/app/features/provisioning/utils/routes.ts +++ b/public/app/features/provisioning/utils/routes.ts @@ -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') ), diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 60d4da1ad59..3c17b9c6c73 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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", "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. Make sure to include these permissions under <4>Repository:", - "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 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",