Provisioning: Align frontend more with designs (#102687)

* feat: align frontend more with designs

* feat: remove unnecessary fields

* chore: remove imports

* chore: move imports

* fix: make label not spit out undefined

* feat: update title of nav item

* chore: make update-workspace

* fix: don't wrap text with span unless required

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
This commit is contained in:
Mariell Hoversholm
2025-03-25 12:59:42 +01:00
committed by GitHub
co-authored by Roberto Jimenez Sanchez
parent f73f88e0f3
commit 75ce8db0e2
14 changed files with 270 additions and 218 deletions
@@ -15,6 +15,8 @@ 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 */
@@ -45,6 +47,7 @@ export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
(
{
label,
useLabel,
description,
horizontal,
invalid,
@@ -63,14 +66,25 @@ export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
const styles = useStyles2(getFieldStyles);
const inputId = htmlFor ?? getChildId(children);
const labelElement =
typeof label === 'string' ? (
let labelElement: React.ReactNode;
if (typeof label === 'string') {
labelElement = (
<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 (
+1 -1
View File
@@ -62,7 +62,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
}
if hasAccess(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAll)) && s.features.IsEnabled(ctx, featuremgmt.FlagProvisioning) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Provisioning",
Text: "Remote provisioning",
Id: "provisioning",
SubTitle: "Manage resources from remote repositories",
Url: s.cfg.AppSubURL + "/admin/provisioning",
@@ -1,6 +1,7 @@
import { css } from '@emotion/css';
import { Box, Stack, Text, LinkButton, Icon, IconName } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { Box, Stack, Text, LinkButton, Icon, IconName, useStyles2 } from '@grafana/ui';
import { FeatureCard } from './FeatureCard';
@@ -29,46 +30,60 @@ interface EnhancedFeaturesProps {
onSetupPublicAccess: () => void;
}
export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPublicAccess }: EnhancedFeaturesProps) => (
<Box marginTop={2}>
<Text variant="h2">Unlock enhanced functionality for GitHub</Text>
<Box marginTop={4}>
<Stack direction="row" gap={2}>
<FeatureCard
title="Instantenous provisioning"
description="Automatically provision and update your dashboards as soon as changes are pushed to your GitHub repository"
icon={<IconCircle icon="sync" color="primary" background="rgba(24, 121, 219, 0.12)" />}
action={
!hasPublicAccess && (
<LinkButton fill="outline" onClick={onSetupPublicAccess}>
Set up public access
</LinkButton>
)
}
showBorder
/>
<FeatureCard
title="Visual previews in pull requests"
description="Review how your changes look like before going live in Grafana and directly in pull requests"
icon={
<Stack direction="row" gap={2}>
<IconCircle icon="camera" color="orange" background="rgba(255, 120, 10, 0.12)" />
<IconCircle icon="code-branch" color="purple" background="rgba(135, 73, 237, 0.12)" />
</Stack>
}
action={
!hasImageRenderer && (
<LinkButton
fill="outline"
href="https://grafana.com/grafana/plugins/grafana-image-renderer/"
icon="external-link-alt"
>
Set up image rendering
</LinkButton>
)
}
/>
</Stack>
export const EnhancedFeatures = ({ hasPublicAccess, hasImageRenderer, onSetupPublicAccess }: EnhancedFeaturesProps) => {
const style = useStyles2(getStyles);
return (
<Box marginTop={2}>
<Text variant="h2">Unlock enhanced functionality for GitHub</Text>
<Box marginTop={4}>
<Stack direction="row" gap={2}>
<FeatureCard
title="Instant updates and pull requests with webhooks"
description="Get instant updates in Grafana as soon as changes are committed. Review and approve changes using pull requests before they go live."
icon={<IconCircle icon="sync" color="primary" background="rgba(24, 121, 219, 0.12)" />}
action={
!hasPublicAccess && (
<LinkButton fill="outline" onClick={onSetupPublicAccess}>
Set up public access
</LinkButton>
)
}
/>
<div className={style.separator} />
<FeatureCard
title="Visual previews in pull requests"
description="See visual previews of dashboard updates directly in pull requests"
icon={
<Stack direction="row" gap={2}>
<IconCircle icon="camera" color="orange" background="rgba(255, 120, 10, 0.12)" />
<IconCircle icon="code-branch" color="purple" background="rgba(135, 73, 237, 0.12)" />
</Stack>
}
action={
!hasImageRenderer && (
<LinkButton
fill="outline"
href="https://grafana.com/grafana/plugins/grafana-image-renderer/"
icon="external-link-alt"
>
Set up image rendering
</LinkButton>
)
}
/>
</Stack>
</Box>
</Box>
</Box>
);
);
};
function getStyles(theme: GrafanaTheme2) {
return {
separator: css({
borderRight: `2px solid ${theme.colors.border.weak}`,
}),
};
}
@@ -1,5 +1,3 @@
import { css } from '@emotion/css';
import { Box, Stack, Text } from '@grafana/ui';
interface FeatureCardProps {
@@ -7,17 +5,11 @@ interface FeatureCardProps {
description: string;
icon?: React.ReactNode;
action?: React.ReactNode;
showBorder?: boolean;
}
export const FeatureCard = ({ title, description, icon, action, showBorder = false }: FeatureCardProps) => (
export const FeatureCard = ({ title, description, icon, action }: FeatureCardProps) => (
<Box width="25%" padding={2}>
<div
className={css({
borderRight: showBorder ? ': 1px solid rgba(204, 204, 220, 0.15);' : undefined,
height: `100%`,
})}
>
<div style={{ height: '100%' }}>
<Stack direction="column" gap={2}>
{icon}
<Text variant="h3">{title}</Text>
@@ -4,14 +4,18 @@ import { Repository } from 'app/api/clients/provisioning';
import { ConnectRepositoryButton } from '../Shared/ConnectRepositoryButton';
interface FeatureItemProps {
children: React.ReactNode;
children: NonNullable<React.ReactNode>;
}
const FeatureItem = ({ children }: FeatureItemProps) => (
<Text variant="body">
<Icon name="check" className="text-success" /> {children}
</Text>
);
const FeatureItem = ({ children }: FeatureItemProps) => {
// We use a stack here to ensure the icon and text are aligned correctly.
return (
<Stack direction="row" gap={1}>
<Icon name="check" className="text-success" />
<Text variant="body">{children}</Text>
</Stack>
);
};
interface FeaturesListProps {
repos?: Repository[];
@@ -48,17 +52,12 @@ export const FeaturesList = ({
return (
<Stack direction="column" gap={2}>
<Text variant="h2">Provisioning as-code directly from Grafana</Text>
<Text variant="h2">Manage your dashboards with remote provisioning</Text>
<FeatureItem>Manage dashboards as code and provision updates automatically</FeatureItem>
<FeatureItem>
Manage your dashboards as code and deploy them automatically from your GitHub repository or local storage
</FeatureItem>
<FeatureItem>
Review, discuss, and approve dashboard changes with your team before they go live using GitHub pull requests
</FeatureItem>
<FeatureItem>
Export your existing dashboards as code and store them in GitHub repositories for version control and
collaboration
Store dashboards in version-controlled storage for better organization and history tracking
</FeatureItem>
<FeatureItem>Migrate existing dashboards to storage for provisioning</FeatureItem>
{hasPublicAccess && (
<FeatureItem>
Automatically provision and update your dashboards as soon as changes are pushed to your GitHub repository
@@ -68,9 +67,12 @@ export const FeaturesList = ({
<FeatureItem>Visual previews in pull requests to review your changes before going live</FeatureItem>
)}
<LinkButton fill="text" href="#" icon="external-link-alt">
Learn more
</LinkButton>
{false && (
// We haven't gotten the design for this quite yet.
<LinkButton fill="text" href="#" icon="external-link-alt">
Learn more
</LinkButton>
)}
{actions()}
</Stack>
@@ -11,7 +11,7 @@ export default function GettingStartedPage({ items }: Props) {
<Page
navId="provisioning"
pageNav={{
text: 'Getting started with Provisioning',
text: 'Remote provisioning',
subTitle:
'Provisioning is a feature that allows you to manage your dashboards using GitHub and other storage systems',
}}
@@ -24,7 +24,7 @@ export function ConnectRepositoryButton({ items }: Props) {
disabled={true}
tooltip={`Max repositories already created (${state.repoCount})`}
>
Maximum repos exist ({state.repoCount})
Maximum repositories exist ({state.repoCount})
</LinkButton>
);
}
@@ -1,36 +1,69 @@
import { ControlledCollapse, TextLink } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { TextLink, useStyles2 } from '@grafana/ui';
export function TokenPermissionsInfo() {
const styles = useStyles2(getStyles);
return (
<ControlledCollapse collapsible label="Access Token Permissions">
<div className={styles.container}>
<div>
To create a new access token, navigate to{' '}
Go to{' '}
<TextLink external href="https://github.com/settings/personal-access-tokens/new">
Personal access tokens
</TextLink>{' '}
GitHub Personal Access Tokens
</TextLink>
. Make sure to include these permissions under <b>Repository</b>:
</div>
<p>Select the appropriate owner and repository. Then expand repository permissions, granting</p>
<table style={{ fontSize: '2em' }}>
<table className={styles.permissionTable}>
<tbody>
<tr className={styles.headerSeparator}>
<th>Permission</th>
<th>Access</th>
</tr>
<tr>
<td>Content</td>
<td>Contents</td>
<td>Read and write</td>
</tr>
<tr>
<td>Metadata</td>
<td>Read only</td>
<td>Read-only</td>
</tr>
<tr>
<td>Pull requests&nbsp;&nbsp;</td>
<td>Pull requests</td>
<td>Read and write</td>
</tr>
<tr>
<td>Webhooks</td>
<td>Read and write &nbsp;&nbsp;</td>
<td>Read and write</td>
</tr>
</tbody>
</table>
</ControlledCollapse>
</div>
);
}
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%',
}),
headerSeparator: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
};
}
@@ -132,7 +132,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
<>
{Boolean(state.resourceCount) && (
<Alert severity="info" title="Note">
Dashboards will be unavailable while running this process.
Dashboards will be unavailable while running this process
</Alert>
)}
{Boolean(state.fileCount) && Boolean(state.resourceCount) && (
@@ -6,7 +6,10 @@ export default function ConnectPage() {
return (
<Page
navId="provisioning"
pageNav={{ text: 'Connect to repository', subTitle: 'Connect to a repository to manage your resources' }}
pageNav={{
text: 'Connect to external storage',
subTitle: 'Connect to an external storage to manage your resources',
}}
>
<Page.Contents>
<ProvisioningWizard />
@@ -29,25 +29,28 @@ export function ConnectStep() {
return (
<Stack direction="column">
<Combobox
options={typeOptions}
value={type}
onChange={(value) => {
const repoType = value?.value;
setValue('repository.type', repoType);
setValue(
'repository.workflows',
getWorkflowOptions(repoType).map((v) => v.value)
);
}}
/>
<Field label="Storage type" required description="Choose the type of storage for your resources">
<Combobox
options={typeOptions}
value={type}
onChange={(value) => {
const repoType = value?.value;
setValue('repository.type', repoType);
setValue(
'repository.workflows',
getWorkflowOptions(repoType).map((v) => v.value)
);
}}
/>
</Field>
{isGithub && (
<>
<TokenPermissionsInfo />
<Field
label={'Token'}
label={'Enter your access token'}
required
description="Paste your GitHub personal access token"
error={errors.repository?.token?.message}
invalid={!!errors.repository?.token}
>
@@ -60,7 +63,7 @@ export function ConnectStep() {
<SecretInput
{...field}
id={'token'}
placeholder={'ghp_yourTokenHere1234567890abcdEFGHijklMNOP'}
placeholder={'github_pat_yourTokenHere1234567890abcdEFGHijklMNOP'}
isConfigured={tokenConfigured}
onReset={() => {
setValue('repository.token', '');
@@ -73,21 +76,22 @@ export function ConnectStep() {
</Field>
<Field
label={'Repository URL'}
label={'Enter your Repository URL'}
error={errors.repository?.url?.message}
invalid={!!errors.repository?.url}
description={'Enter the GitHub repository URL'}
description={'Paste the URL of your GitHub repository'}
required
>
<Input
{...register('repository.url', {
required: 'This field is required.',
pattern: {
// TODO: The regex is not correct when we support GHES.
value: /^(?:https:\/\/github\.com\/)?[^/]+\/[^/]+$/,
message: 'Please enter a valid GitHub repository URL',
},
})}
placeholder={'https://github.com/username/repo-name'}
placeholder={'https://github.com/username/repo'}
/>
</Field>
@@ -1,22 +1,18 @@
import { css } from '@emotion/css';
import { useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Alert, Field, FieldSet, Input, MultiCombobox, Stack, Switch, Text } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, Input, MultiCombobox, Stack, Switch, useStyles2 } from '@grafana/ui';
import { getWorkflowOptions } from '../Config/ConfigForm';
import { checkPublicAccess, checkImageRenderer } from '../GettingStarted/features';
import { GETTING_STARTED_URL } from '../constants';
import { WizardFormData } from './types';
export function FinishStep() {
const {
register,
watch,
control,
formState: { errors },
} = useFormContext<WizardFormData>();
const { register, watch, control, formState } = useFormContext<WizardFormData>();
const { errors } = formState;
const type = watch('repository.type');
const isGithub = type === 'github';
@@ -24,7 +20,18 @@ export function FinishStep() {
const hasImageRenderer = checkImageRenderer();
// Enable sync by default
const { setValue } = useFormContext<WizardFormData>();
const navigate = useNavigate();
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(() => {
@@ -33,107 +40,89 @@ export function FinishStep() {
return (
<Stack direction="column">
<FieldSet label="Automatic pulling">
{isGithub && isPublic && (
<Stack>
<Alert severity="info" title="Instantenous provisioning available">
Automatically provision and update your dashboards as soon as changes are pushed to your GitHub
repository.
</Alert>
</Stack>
)}
{isGithub && !isPublic && (
<Alert
title={'Public URL not configured'}
severity="info"
buttonContent={<span>Instructions</span>}
onRemove={() => navigate(GETTING_STARTED_URL)}
>
Changes in git will eventually be pulled depending on the synchronization interval.
</Alert>
)}
<Field label={'Interval (seconds)'}>
{isGithub && (
<Field
label="Update instance interval (seconds)"
description="How often shall the instance pull updates from GitHub?"
required
>
<Input
{...register('repository.sync.intervalSeconds', { valueAsNumber: true })}
type={'number'}
placeholder={'60'}
type="number"
placeholder="60"
/>
</Field>
</FieldSet>
<FieldSet label="Collaboration">
)}
<Field
label="Workflows"
description="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: 'This field is required.' }}
render={({ field: { ref, onChange, ...field } }) => (
<MultiCombobox
options={getWorkflowOptions(type)}
placeholder="Read-only repository"
onChange={(val) => {
onChange(val.map((v) => v.value));
}}
{...field}
/>
)}
/>
</Field>
{isGithub && false /* TODO */ && (
<Field
label={'Workflows'}
required
error={errors.repository?.workflows?.message}
invalid={!!errors.repository?.workflows}
label={'Enable webhooks on changes' /* TODO: Link to docs when !isPublic */}
description="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}
>
<Controller
name={'repository.workflows'}
control={control}
rules={{ required: 'This field is required.' }}
render={({ field: { ref, onChange, ...field } }) => {
return (
<MultiCombobox
options={getWorkflowOptions(type)}
placeholder={'Readonly repository'}
onChange={(val) => {
onChange(val.map((v) => v.value));
}}
{...field}
/>
);
}}
/>
{/* TODO: Make an option for the switch to control */}
<Switch id="repository.webhook.enable" />
</Field>
{isGithub && (
<>
{isPublic ? (
<Alert severity="info" title="Preview links available">
Preview links will be automatically added to pull requests when changes are made.
</Alert>
) : (
<Alert
severity="info"
title="Public URL not configured"
onRemove={() => navigate(GETTING_STARTED_URL)}
buttonContent={<span>Instructions</span>}
>
Preview links in pull requests will not be available until a public URL is configured.
</Alert>
)}
)}
{!hasImageRenderer && (
<Alert
severity="info"
title="Image renderer not configured"
onRemove={() => navigate(GETTING_STARTED_URL)}
buttonContent={<span>Instructions</span>}
>
The image renderer is not configured. Preview images will not be available.
</Alert>
)}
{hasImageRenderer && isPublic && (
<>
<Field
label={'Attach dashboard previews to pull requests'}
description={
<Text element="span">Render before/after images and link them to the pull request.</Text>
}
>
<Switch
{...register('repository.generateDashboardPreviews')}
id={'repository.generateDashboardPreviews'}
/>
</Field>
<Alert severity="info" title="Note">
This will render dashboards into an image that can be access by a public URL
</Alert>
</>
)}
</>
)}
</FieldSet>
{isGithub && (
<Field
useLabel
label={
<span>
Enable dashboard previews in pull requests{' '}
<span className={style.explanation}>
(Requires image rendering.{' '}
<a className={style.explanationLink} href="https://grafana.com">
Set up image rendering
</a>
)
</span>
</span>
}
description="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>
)}
</Stack>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
explanation: css({
color: theme.colors.text.disabled,
fontStyle: 'italic',
}),
explanationLink: css({
color: theme.colors.text.link,
fontStyle: 'italic',
}),
};
}
@@ -12,7 +12,7 @@ import { WizardContent } from './WizardContent';
import { WizardFormData, WizardStep } from './types';
const steps: Array<Step<WizardStep>> = [
{ id: 'connection', name: 'Connect', title: 'Connect to repository', submitOnNext: true },
{ id: 'connection', name: 'Connect', title: 'Connect to external storage', submitOnNext: true },
{ id: 'bootstrap', name: 'Bootstrap', title: 'Bootstrap repository', submitOnNext: true },
{ id: 'migrate', name: 'Resources', title: 'Migrate resources', submitOnNext: false },
{ id: 'pull', name: 'Resources', title: 'Pull resources', submitOnNext: false },
@@ -10,21 +10,21 @@ const migrateInstance: ModeOption = {
target: 'instance',
operation: 'migrate',
label: 'Migrate instance to repository',
description: 'Save all Grafana resources 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 repository into this Grafana instance',
description: 'Pull resources from the repository into this Grafana instance',
};
const pullFolder: ModeOption = {
target: 'folder',
operation: 'pull',
label: 'Pull from repository to folder',
description: 'Pull repository resources into a specific folder',
description: 'Pull repository resources into a repository-managed Grafana folder',
};
function getDisabledReason(action: ModeOption, resourceCount: number, folderConnected?: boolean) {
@@ -80,7 +80,7 @@ export function getState(
const state: SystemState = {
resourceCount,
resourceCountString: counts.join(','),
resourceCountString: counts.join(',\n'),
fileCount,
actions: [],
disabled: [],