[Provisioning] Add more explicit setup warnings if webhook integration and image rendering are disabled (#101304)

* Setup warnings if renders or webhooks are not possible

* Improve display

* Use a single Alert

* Make design more compact

* Only display local config is missing critical feature toggles

* Improve styling of required flags

* Add file name to the custom ini itself

* Add copy button

* Add FeatureSection Component

* Commit betterer

* Use an interactive table

* Use a modal for instructions

* Use the same modal for example config

* Improve setup steps

* Improve stepper

* Copy code ctrl + c

* Make it more compact

* Select feature to enable

* Improve the height of alerts

* Separate components

* Better warnings

* Improve the page

* Improve the cards

* Improve cards even more

* Improve cards

* Improve cards

* Optional copy in code block

* Add side bar with steps

* Improve styling

* Style modal

* Clean up Code

* Remove index file

* Simplify Step Component

* Commit betterer

* Simplify components

* Use CodeEditor and Clipboard components

* Do not show scrollback on minimum size

* Fix positioning of footer

* Separate Component for Feature

* Use different styling

* Commit betterer

* Use more Grafana components in the FeatureCard

* Separate sidebar into own components

* Simplify sidebar code

* Commit betterer

* Remove connector

* Simplify styling further

* Use cards

* Improve code

* Use more grafana component in InstructionsModal

* Further simplify

* Simplify the code

* Simplify style

* Clean up

* Simplify the Wizard

* Use little icons

* Improve feature cards

* Improve cards

* Commit betterer

* Add description to feature setup

* Improve instructions for snapshot preview

* Move all files into Setup folder

* Commit betterer

* Clean up the warnings code

* Improve coding

* Move sidebar item to separate fiel

* Rename components

* Fix issues

* Use stack instead

* Improve style

* Don't show setup button if configured already

* Simplify again CSS

* Use secondary actions

* Style a bit more

* Improve wording

* Update warning

* Refer to docs in Image Renderer

* More clean up

* Revert changes in generated client
This commit is contained in:
Roberto Jiménez Sánchez
2025-02-28 10:04:57 +01:00
committed by GitHub
parent b94e192c0a
commit 52d32c7ff8
24 changed files with 789 additions and 168 deletions
+16 -5
View File
@@ -5739,16 +5739,27 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "15"]
],
"public/app/features/provisioning/SetupWarnings.tsx:5381": [
"public/app/features/provisioning/Setup/FeatureCard.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/provisioning/Setup/FeatureList.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/provisioning/Setup/SetupModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/provisioning/Setup/StatusAlerts.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/provisioning/SyncRepository.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@@ -20,18 +20,20 @@ type screenshotRenderer struct {
render rendering.Service
blobstore resource.BlobStoreClient
urlProvider func(namespace string) string
isPublic bool
}
func NewScreenshotRenderer(render rendering.Service, blobstore resource.BlobStoreClient, urlProvider func(namespace string) string) *screenshotRenderer {
func NewScreenshotRenderer(render rendering.Service, blobstore resource.BlobStoreClient, isPublic bool, urlProvider func(namespace string) string) *screenshotRenderer {
return &screenshotRenderer{
render: render,
blobstore: blobstore,
urlProvider: urlProvider,
isPublic: isPublic,
}
}
func (r *screenshotRenderer) IsAvailable(ctx context.Context) bool {
return r.render != nil && r.render.IsAvailable(ctx)
return r.render != nil && r.render.IsAvailable(ctx) && r.blobstore != nil && r.isPublic
}
func (r *screenshotRenderer) RenderDashboardPreview(ctx context.Context, namespace, repoName, path, ref string) (string, error) {
+22 -12
View File
@@ -70,6 +70,7 @@ var (
type APIBuilder struct {
urlProvider func(namespace string) string
webhookSecretKey string
isPublic bool
features featuremgmt.FeatureToggles
getter rest.Getter
@@ -107,10 +108,15 @@ func NewAPIBuilder(
secrets secrets.Service,
) *APIBuilder {
clientFactory := resources.NewFactory(configProvider)
// HACK: Assume is only public if it is HTTPS
isPublic := strings.HasPrefix(urlProvider(""), "https://")
return &APIBuilder{
urlProvider: urlProvider,
localFileResolver: local,
webhookSecretKey: webhookSecretKey,
isPublic: isPublic,
features: features,
ghFactory: ghFactory,
client: clientFactory,
@@ -220,8 +226,9 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage
storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = &webhookConnector{
getter: b,
jobs: b.jobs,
getter: b,
jobs: b.jobs,
webhooksEnabled: b.isPublic,
}
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = &testConnector{
getter: b,
@@ -418,7 +425,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
))
// Pull request worker
renderer := pullrequest.NewScreenshotRenderer(b.render, b.unified, b.urlProvider)
renderer := pullrequest.NewScreenshotRenderer(b.render, b.unified, b.isPublic, b.urlProvider)
previewer := pullrequest.NewPreviewer(renderer, b.urlProvider)
pullRequestWorker, err := pullrequest.NewPullRequestWorker(b.parsers, previewer)
if err != nil {
@@ -916,15 +923,18 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
return repository.NewLocal(r, b.localFileResolver), nil
case provisioning.GitHubRepositoryType:
gvr := provisioning.RepositoryResourceInfo.GroupVersionResource()
webhookURL := fmt.Sprintf(
"%sapis/%s/%s/namespaces/%s/%s/%s/webhook",
b.urlProvider(r.GetNamespace()),
gvr.Group,
gvr.Version,
r.GetNamespace(),
gvr.Resource,
r.GetName(),
)
var webhookURL string
if b.isPublic {
webhookURL = fmt.Sprintf(
"%sapis/%s/%s/namespaces/%s/%s/%s/webhook",
b.urlProvider(r.GetNamespace()),
gvr.Group,
gvr.Version,
r.GetNamespace(),
gvr.Resource,
r.GetName(),
)
}
return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL)
default:
return nil, errors.New("unknown repository type")
@@ -805,18 +805,11 @@ func (r *githubRepository) deleteWebhook(ctx context.Context) error {
return nil
}
func (r *githubRepository) supportsWebhook() bool {
u, err := url.Parse(r.webhookURL)
if err != nil || u.Hostname() == "localhost" {
return false
}
return true
}
func (r *githubRepository) OnCreate(ctx context.Context) (*provisioning.WebhookStatus, error) {
if !r.supportsWebhook() {
if len(r.webhookURL) == 0 {
return nil, nil
}
ctx, _ = r.logger(ctx, "")
hook, err := r.createWebhook(ctx)
if err != nil {
@@ -831,7 +824,7 @@ func (r *githubRepository) OnCreate(ctx context.Context) (*provisioning.WebhookS
}
func (r *githubRepository) OnUpdate(ctx context.Context) (*provisioning.WebhookStatus, error) {
if !r.supportsWebhook() {
if len(r.webhookURL) == 0 {
return nil, nil
}
ctx, _ = r.logger(ctx, "")
@@ -849,6 +842,9 @@ func (r *githubRepository) OnUpdate(ctx context.Context) (*provisioning.WebhookS
}
func (r *githubRepository) OnDelete(ctx context.Context) error {
if len(r.webhookURL) == 0 {
return nil
}
ctx, _ = r.logger(ctx, "")
return r.deleteWebhook(ctx)
}
+7 -2
View File
@@ -20,8 +20,9 @@ import (
// This only works for github right now
type webhookConnector struct {
getter RepoGetter
jobs jobs.JobQueue
getter RepoGetter
jobs jobs.JobQueue
webhooksEnabled bool
}
func (*webhookConnector) New() runtime.Object {
@@ -65,6 +66,10 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := logging.FromContext(r.Context()).With("logger", "webhook-connector", "repo", name)
ctx := logging.Context(r.Context(), logger)
if !s.webhooksEnabled {
responder.Error(errors.NewBadRequest("webhooks are not enabled"))
return
}
hooks, ok := repo.(repository.Hooks)
if !ok {
@@ -3583,6 +3583,9 @@
"description": "Summary shows a view of the configuration that is sanitized and is OK for logged in users to see",
"type": "object",
"required": [
"legacyStorage",
"generateDashboardPreviews",
"githubWebhooks",
"items"
],
"properties": {
@@ -3590,6 +3593,16 @@
"description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
"type": "string"
},
"generateDashboardPreviews": {
"description": "GenerateDashboardPreviews is available to render dashboard previews",
"type": "boolean",
"default": false
},
"githubWebhooks": {
"description": "GithubWebhooks is available to sync changes from a repository as soon as they are pushed or react to pull requests",
"type": "boolean",
"default": false
},
"items": {
"type": "array",
"items": {
@@ -3607,7 +3620,8 @@
},
"legacyStorage": {
"description": "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true",
"type": "boolean"
"type": "boolean",
"default": false
}
}
},
@@ -1,7 +1,7 @@
import { Page } from 'app/core/components/Page/Page';
import { ConfigForm } from './ConfigForm';
import { SetupWarnings } from './SetupWarnings';
import { StatusAlerts } from './Setup/StatusAlerts';
export default function NewRepositoryPage() {
return (
@@ -10,7 +10,7 @@ export default function NewRepositoryPage() {
pageNav={{ text: 'Configure repository', subTitle: 'Configure a repository for storing your resources.' }}
>
<Page.Contents>
<SetupWarnings />
<StatusAlerts />
<ConfigForm />
</Page.Contents>
</Page>
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { EmptyState, LinkButton, Alert, Stack, Text, Button } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { SetupWarnings } from './SetupWarnings';
import { StatusAlerts } from './Setup/StatusAlerts';
import { useGetFrontendSettingsQuery } from './api';
import { NEW_URL, MIGRATE_URL } from './constants';
@@ -17,7 +17,7 @@ export default function OnboardingPage({ legacyStorage }: { legacyStorage?: bool
pageNav={{ text: 'Setup provisioning', subTitle: 'Configure this instance to use provisioning' }}
>
<Page.Contents>
<SetupWarnings />
<StatusAlerts />
{legacyStorage ? (
<>
<Alert severity="info" title="Setting up this connection could cause a temporary outage">
@@ -20,7 +20,7 @@ import { Page } from 'app/core/components/Page/Page';
import { DeleteRepositoryButton } from './DeleteRepositoryButton';
import OnboardingPage from './OnboardingPage';
import { SetupWarnings } from './SetupWarnings';
import { StatusAlerts } from './Setup/StatusAlerts';
import { StatusBadge } from './StatusBadge';
import { SyncRepository } from './SyncRepository';
import { Repository, ResourceCount, useDeletecollectionRepositoryMutation, useGetFrontendSettingsQuery } from './api';
@@ -56,7 +56,7 @@ export default function RepositoryListPage() {
return (
<Page navId="provisioning" subTitle="View and manage your configured repositories">
<Page.Contents isLoading={isLoading}>
<SetupWarnings />
<StatusAlerts />
{settings.data?.legacyStorage && (
<Alert
title="Legacy storage detected"
@@ -0,0 +1,56 @@
import { Button, Icon, IconName, Text, Card, Stack } from '@grafana/ui';
import { Feature } from './types';
interface Props {
feature: Feature;
onSetup: () => void;
showSetupButton?: boolean;
}
export const FeatureCard = ({ feature, onSetup, showSetupButton = true }: Props) => {
const isConfigured = feature.isConfigured;
const iconName = (feature.icon || 'apps') as IconName;
return (
<Card>
<Card.Heading>
<Stack justifyContent="center" width="100%">
<Text element="h2" variant="h2">
{feature.title}
</Text>
</Stack>
</Card.Heading>
<Card.Description>
<Stack direction="column" gap={2} justifyContent="center" alignItems="center">
<Icon name={iconName} size="xxxl" />
<Text color="secondary">{feature.description}</Text>
</Stack>
</Card.Description>
{(feature.docsLink || showSetupButton) && (
<Card.Actions>
<Stack justifyContent="center" width="100%">
{showSetupButton && !isConfigured && onSetup && feature.setupSteps.length > 0 && (
<Button variant="primary" onClick={onSetup}>
Set Up Now
</Button>
)}
{feature.docsLink && (
<Button variant="secondary" onClick={() => window.open(feature.docsLink, '_blank')}>
Read Docs
</Button>
)}
</Stack>
</Card.Actions>
)}
<Card.SecondaryActions>
{isConfigured ? (
<Icon name="check-circle" color="green" size="lg" />
) : (
<Icon name="exclamation-triangle" size="lg" color={feature.additional ? 'orange' : 'red'} />
)}
</Card.SecondaryActions>
</Card>
);
};
@@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import { Text, Stack } from '@grafana/ui';
import { Feature, feature_ini, ngrok_example, root_url_ini } from './types';
import { SetupModal } from './SetupModal';
import { FeatureCard } from './FeatureCard';
import { getConfigurationStatus } from './utils';
export const FeatureList = () => {
const [features, setFeatures] = useState<Feature[]>([]);
const [showInstructionsModal, setShowModal] = useState(false);
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { hasPublicAccess, hasImageRenderer, hasRequiredFeatures } = getConfigurationStatus();
useEffect(() => {
const enableFeatureSteps = [
{
title: 'Enable Required Feature Toggles',
description: 'Add these settings to your custom.ini file to enable necessary features:',
code: feature_ini,
},
];
// Initialize features with their current status
const featuresList: Feature[] = [
{
title: 'Provision As-Code',
description:
'Manage your dashboards as code and deploy them automatically from your Github repository or local storage',
additional: false,
setupSteps: enableFeatureSteps,
isConfigured: hasRequiredFeatures,
docsLink: 'https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles',
icon: 'sync',
},
{
title: 'Pull Requests',
description:
'Review, discuss, and approve dashboard changes with your team before they go live using Github pull requests',
additional: false,
setupSteps: enableFeatureSteps,
icon: 'code-branch',
isConfigured: hasRequiredFeatures,
docsLink: 'https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles',
},
{
title: 'Export As-Code',
description:
'Export your existing dashboards as code and store them in GitHub repositories for version control and collaboration',
additional: false,
setupSteps: enableFeatureSteps,
icon: 'cloud-upload',
isConfigured: hasRequiredFeatures,
docsLink: 'https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles',
},
{
title: 'Github Webhooks',
description:
'Automatically provision and update your dashboards as soon as changes are pushed to your Github repository',
additional: true,
icon: 'github',
isConfigured: hasPublicAccess && hasRequiredFeatures,
setupSteps: [
{
title: 'Start ngrok for temporary public access',
description: 'Run this command to create a secure tunnel to your local Grafana:',
code: 'ngrok http 3000',
},
{
title: 'Copy your public URL',
description: 'From the ngrok output, copy the https:// forwarding URL that looks like this:',
code: ngrok_example,
copyCode: false,
},
{
title: 'Update your Grafana configuration',
description: 'Add this to your custom.ini file, replacing the URL with your actual ngrok URL:',
code: root_url_ini,
},
],
},
{
title: 'Previews',
description:
'Preview dashboard changes visually in pull requests before they go live, making team reviews faster and more effective',
icon: 'camera',
additional: true,
isConfigured: hasImageRenderer && hasPublicAccess && hasRequiredFeatures,
docsLink: 'https://grafana.com/grafana/plugins/grafana-image-renderer/',
setupSteps: [],
},
];
setFeatures(featuresList);
}, []);
// Add a state variable to store the basic setup
const [basicSetup] = useState<Feature>({
title: 'Provisioning',
description: 'Enable required Grafana features for provisioning',
additional: false,
icon: 'cog',
isConfigured: hasRequiredFeatures,
setupSteps: [
{
title: 'Enable Required Feature Toggles',
description: 'Add these settings to your custom.ini file to enable necessary features:',
code: feature_ini,
},
],
});
const showModal = (feature: Feature) => {
// only show modal if feature is not configured
if (!feature.isConfigured) {
setActiveFeature(feature);
setShowModal(true);
}
};
const onDismiss = () => {
setShowModal(false);
setActiveFeature(null);
};
// Separate required and optional features
const requiredFeatures = features.filter((feature) => !feature.additional);
const optionalFeatures = features.filter((feature) => feature.additional);
return (
<Stack direction="column" gap={4}>
<Text element="h1" variant="h2" textAlignment="center">
All Features
</Text>
<Stack direction="row" gap={2} justifyContent="center">
{requiredFeatures.map((feature, index) => (
<FeatureCard
key={index}
feature={feature}
onSetup={() => showModal(basicSetup)}
showSetupButton={!feature.isConfigured}
/>
))}
{optionalFeatures.map((feature, index) => (
<FeatureCard
key={index}
feature={feature}
onSetup={() => showModal(feature)}
showSetupButton={!feature.isConfigured}
/>
))}
</Stack>
{showInstructionsModal && activeFeature && (
<SetupModal feature={activeFeature} isOpen={true} onDismiss={onDismiss} />
)}
</Stack>
);
};
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { Modal, Button, useStyles2, Stack, Text } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { Feature } from './types';
import { SetupStep } from './SetupStep';
import { Sidebar } from './Sidebar';
export interface Props {
feature: Feature;
isOpen: boolean;
onDismiss: () => void;
}
export const SetupModal = ({ feature, isOpen, onDismiss }: Props) => {
const styles = useStyles2(getStyles);
const [currentStep, setCurrentStep] = useState(0);
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === feature.setupSteps.length - 1;
const stepTitles = feature.setupSteps.map((step) => step.title);
const handleNext = () => !isLastStep && setCurrentStep(currentStep + 1);
const handlePrevious = () => !isFirstStep && setCurrentStep(currentStep - 1);
return (
<Modal isOpen={isOpen} title={`Setup ${feature.title}`} onDismiss={onDismiss} className={styles.modal}>
<div style={{ marginTop: '-16px', marginBottom: '20px' }}>
<Text variant="body" color="secondary">
{feature.description}
</Text>
</div>
<Stack direction="row" height="100%">
<Sidebar steps={stepTitles} currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={styles.contentWrapper}>
<SetupStep step={feature.setupSteps[currentStep]} />
</div>
</Stack>
<div className={styles.footer}>
<Stack direction="row" justifyContent="flex-end" gap={2}>
<Button variant="secondary" onClick={handlePrevious} disabled={isFirstStep}>
Previous
</Button>
{isLastStep ? (
<Button variant="primary" onClick={onDismiss} icon="check-circle">
Done
</Button>
) : (
<Button variant="primary" onClick={handleNext}>
Next
</Button>
)}
</Stack>
</div>
</Modal>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
modal: css`
width: 1100px;
max-width: 95%;
`,
description: css`
margin-bottom: ${theme.spacing(3)};
padding: 0 ${theme.spacing(1)};
`,
contentWrapper: css`
flex: 1;
overflow-y: auto;
min-width: 0;
padding: ${theme.spacing(2)};
border-left: 1px solid ${theme.colors.border.weak};
`,
footer: css`
padding: ${theme.spacing(2)};
border-top: 1px solid ${theme.colors.border.weak};
margin-top: ${theme.spacing(2)};
`,
});
@@ -0,0 +1,17 @@
import { Page } from 'app/core/components/Page/Page';
import { StatusAlerts } from './StatusAlerts';
import { FeatureList } from './FeatureList';
export default function SetupPage() {
return (
<Page
navModel={{ main: { text: '' }, node: { text: 'Provisioning Setup' } }}
subTitle="Configure your Grafana instance to use provisioning to manage your dashboards using Github and other storage systems."
>
<Page.Contents>
<StatusAlerts showSetupButton={false} showSuccessBanner={true} />
<FeatureList />
</Page.Contents>
</Page>
);
}
@@ -0,0 +1,24 @@
import { Container, Text } from '@grafana/ui';
import { Step } from './types';
import { CodeBlock } from '../components/CodeBlock';
export interface Props {
step: Step;
}
export const SetupStep = ({ step }: Props) => {
return (
<>
<Text element="h3" variant="h4">
{step?.title}
</Text>
{step.description && (
<Container margin="sm">
<Text element="p">{step.description}</Text>
</Container>
)}
{step.code && <CodeBlock code={step.code} copyCode={step.copyCode} />}
</>
);
};
@@ -0,0 +1,44 @@
import { useStyles2, Stack } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SidebarItem, getStyles as getStepItemStyles } from './SidebarItem';
interface Props {
steps: string[];
currentStep: number;
onStepClick: (index: number) => void;
}
export const Sidebar = ({ steps, currentStep, onStepClick }: Props) => {
if (steps.length === 0 || steps.length === 1) {
return null;
}
const styles = useStyles2(getStyles);
const stepItemStyles = useStyles2(getStepItemStyles);
return (
<div className={styles.sidebar}>
<Stack direction="column" gap={1}>
{steps.map((step, index) => (
<SidebarItem
key={index}
step={step}
index={index}
currentStep={currentStep}
onStepClick={onStepClick}
styles={stepItemStyles}
/>
))}
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
sidebar: css`
width: 260px;
padding: ${theme.spacing(1)};
overflow-y: auto;
`,
});
@@ -0,0 +1,70 @@
import { IconButton, Text, Stack, Card } from '@grafana/ui';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export interface Props {
step: string;
index: number;
currentStep: number;
onStepClick: (index: number) => void;
styles: ReturnType<typeof getStyles>;
}
export const SidebarItem = ({ step, index, currentStep, onStepClick, styles }: Props) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isPending = index > currentStep;
const getStepStatus = () => {
if (isCompleted) return { icon: 'check-circle' as const, color: 'success', label: 'Completed step' };
if (isCurrent) return { icon: 'circle' as const, color: 'primary', label: 'Current step' };
return { icon: 'circle' as const, color: 'secondary', label: 'Pending step' };
};
const { icon, color, label } = getStepStatus();
const handleClick = () => onStepClick(index);
const handleIconClick = (e: React.MouseEvent) => {
e.stopPropagation();
onStepClick(index);
};
return (
<Card
className={`${styles.stepItem} ${isCurrent ? styles.activeStep : ''} ${styles.plainCard}`}
onClick={handleClick}
>
<Stack direction="row" alignItems="center" gap={2}>
<IconButton
name={icon}
size="sm"
variant={isPending ? 'secondary' : 'primary'}
color={color}
aria-label={label}
onClick={handleIconClick}
/>
<Text color={isCurrent ? 'primary' : 'secondary'} weight={isCurrent ? 'medium' : 'regular'}>
{step}
</Text>
</Stack>
</Card>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
stepItem: css`
padding: ${theme.spacing(1)};
cursor: pointer;
&:hover {
background: ${theme.colors.action.hover};
}
`,
activeStep: css`
color: ${theme.colors.primary.text};
`,
plainCard: css`
background: transparent;
border: none;
box-shadow: none;
`,
});
@@ -0,0 +1,64 @@
import { locationService } from '@grafana/runtime';
import { Alert } from '@grafana/ui';
import { getConfigurationStatus } from './utils';
interface Props {
/**
* Whether to show the "Setup Now" button
* @default true
*/
showSetupButton?: boolean;
/**
* Whether to show a success banner when everything is configured
* @default false
*/
showSuccessBanner?: boolean;
}
export function StatusAlerts({ showSetupButton = true, showSuccessBanner = false }: Props) {
const { everythingConfigured, missingRequiredFeatures } = getConfigurationStatus();
// Early return if everything is configured and we don't want to show success banner
if (everythingConfigured && !showSuccessBanner) {
return null;
}
// Handle success case
if (everythingConfigured) {
return (
<Alert severity="success" title="All Features Configured">
All required and optional features are properly configured. Your system is ready to use.
</Alert>
);
}
const handleSetupClick = () => {
locationService.push('/admin/provisioning/setup');
};
const commonSetupButtonProps = showSetupButton
? {
buttonContent: 'Set Up Now',
onRemove: handleSetupClick,
}
: {};
// Required features missing - show error alert
if (missingRequiredFeatures) {
return (
<Alert severity="error" title="Required Features Not Configured" {...commonSetupButtonProps}>
Some required features are not properly configured. Please complete the setup for these features to ensure the
system runs properly.
</Alert>
);
}
// Only optional features missing - show info alert
return (
<Alert severity="info" title="Additional Features Not Configured" {...commonSetupButtonProps}>
Some additional features like Github webhook integration or Previews are not configured. These features can
enhance your experience.
</Alert>
);
}
@@ -0,0 +1,86 @@
import { FeatureToggles } from '@grafana/data';
export interface Step {
title: string;
description?: string;
code?: string;
copyCode?: boolean;
}
export interface Feature {
title: string;
description: string;
additional: boolean;
icon?: string; // Optional icon name for the feature
setupSteps: Step[];
isConfigured: boolean;
docsLink?: string;
}
// List of required feature toggles
export const requiredFeatureToggles: Array<keyof FeatureToggles> = [
'provisioning',
'kubernetesDashboards',
'kubernetesClientDashboardsFolders',
'unifiedStorageSearch',
'unifiedStorageSearchUI',
];
// Configuration examples
export const feature_ini = `# In your custom.ini file
app_mode = development
[feature_toggles]
provisioning = true
kubernetesDashboards = true
unifiedStorageSearch = true
unifiedStorageSearchUI = true
kubernetesClientDashboardsFolders = true
# If you want easy kubectl setup development mode
grafanaAPIServerEnsureKubectlAccess = true`;
// Configuration examples
export const custom_ini = `# In your custom.ini file
app_mode = development
[feature_toggles]
provisioning = true
kubernetesDashboards = true
unifiedStorageSearch = true
unifiedStorageSearchUI = true
kubernetesClientDashboardsFolders = true
# If you want easy kubectl setup development mode
grafanaAPIServerEnsureKubectlAccess = true
# For Github webhook support, you will need something like:
[server]
root_url = https://supreme-exact-beetle.ngrok-free.app
# For dashboard preview generation, you will need something like:
[rendering]
server_url = http://localhost:8081/render
callback_url = http://localhost:3000/
`;
export const ngrok_example = `ngrok http 3000
Help shape K8s Bindings https://ngrok.com/new-features-update?ref=k8s
Session Status online
Account Roberto Jiménez Sánchez (Plan: Free)
Version 3.18.4
Region Europe (eu)
Latency 44ms
Web Interface http://127.0.0.1:4040
Forwarding https://d60d-83-33-235-27.ngrok-free.app -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
50 2 0.00 0.00 83.03 90.56
HTTP Requests
-------------
09:18:46.147 CET GET /favicon.ico 302 Found
09:18:46.402 CET GET /login`;
export const root_url_ini = `[server]
root_url = https://d60d-83-33-235-27.ngrok-free.app`;
@@ -0,0 +1,46 @@
import { config } from '@grafana/runtime';
import { requiredFeatureToggles } from './types';
/**
* Checks if all required feature toggles are enabled
* @returns true if all required feature toggles are enabled
*/
export const checkRequiredFeatures = (): boolean => {
const featureToggles = config.featureToggles || {};
return requiredFeatureToggles.every((toggle) => featureToggles[toggle]);
};
/**
* Checks if public access is configured
* @returns true if the app URL is configured for external access
*/
export const checkPublicAccess = (): boolean => {
return Boolean(config.appUrl && config.appUrl !== 'http://localhost:3000/');
};
/**
* Checks if image renderer is configured
* @returns true if the image renderer is available
*/
export const checkImageRenderer = (): boolean => {
return Boolean(config.rendererAvailable);
};
/**
* Returns the configuration status of all features
* @returns Object containing the status of required and optional features
*/
export const getConfigurationStatus = () => {
const hasRequiredFeatures = checkRequiredFeatures();
const hasPublicAccess = checkPublicAccess();
const hasImageRenderer = checkImageRenderer();
return {
hasRequiredFeatures,
hasPublicAccess,
hasImageRenderer,
missingOnlyOptionalFeatures: hasRequiredFeatures && (!hasPublicAccess || !hasImageRenderer),
missingRequiredFeatures: !hasRequiredFeatures,
everythingConfigured: hasRequiredFeatures && hasPublicAccess && hasImageRenderer,
};
};
@@ -1,13 +0,0 @@
import { Page } from 'app/core/components/Page/Page';
import { SetupWarnings } from './SetupWarnings';
export default function SetupWarningPage() {
return (
<Page navModel={{ main: { text: '' }, node: { text: 'Provisioning' } }} subTitle="Provisioning is not configured">
<Page.Contents>
<SetupWarnings />
</Page.Contents>
</Page>
);
}
@@ -1,114 +0,0 @@
import { useLocalStorage } from 'react-use';
import { FeatureToggles } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, Text, Collapse } from '@grafana/ui';
const requiredFeatureToggles: Array<keyof FeatureToggles> = [
'provisioning',
'kubernetesDashboards',
'kubernetesClientDashboardsFolders',
'unifiedStorageSearch',
'unifiedStorageSearchUI',
];
const custom_ini = `app_mode = development
[feature_toggles]
provisioning = true
kubernetesDashboards = true
unifiedStorageSearch = true
unifiedStorageSearchUI = true
kubernetesClientDashboardsFolders = true
# If you want easy kubectl setup development mode
grafanaAPIServerEnsureKubectlAccess = true
# For Github webhook support, you will need something like:
[server]
root_url = https://supreme-exact-beetle.ngrok-free.app`;
const ngrok_example = `ngrok http 3000
Help shape K8s Bindings https://ngrok.com/new-features-update?ref=k8s
Session Status online
Account Roberto Jiménez Sánchez (Plan: Free)
Version 3.18.4
Region Europe (eu)
Latency 44ms
Web Interface http://127.0.0.1:4040
Forwarding https://d60d-83-33-235-27.ngrok-free.app -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
50 2 0.00 0.00 83.03 90.56
HTTP Requests
-------------
09:18:46.147 CET GET /favicon.ico 302 Found
09:18:46.402 CET GET /login`;
const webhook_ini = `...
[server]
root_url = https://d60d-83-33-235-27.ngrok-free.app`;
export function SetupWarnings() {
const [isCustomIniOpen, setCustomIniOpen] = useLocalStorage('collapse_custom_ini', true);
const [isWebhookOpen, setWebhookOpen] = useLocalStorage('collapse_webhook', true);
const missingFeatures = requiredFeatureToggles.filter((feature) => !config.featureToggles[feature]);
const handleCustomIniToggle = () => {
setCustomIniOpen(!isCustomIniOpen);
};
const handleWebhookToggle = () => {
setWebhookOpen(!isWebhookOpen);
};
if (missingFeatures.length === 0) {
return null;
}
return (
<>
<Alert title="Provisioning Setup Error" severity="error">
{missingFeatures.map((feature) => (
<Text key={feature} element="p">
Missing required feature toggle: <strong>{feature}</strong>
</Text>
))}
</Alert>
<Collapse
isOpen={isCustomIniOpen}
label="Working custom.ini for local testing"
onToggle={handleCustomIniToggle}
collapsible
>
<Alert severity="info" title="">
<pre>
<code>{custom_ini}</code>
</pre>
<Text element="h5">
NOTE: the above config is <strong>not</strong> this machine's config
</Text>
</Alert>
</Collapse>
<Collapse isOpen={isWebhookOpen} label="Webhook Support" onToggle={handleWebhookToggle} collapsible>
<Alert severity="info" title="">
<Text element="h5">Webhook support requires the server to run on a public URL.</Text>
<pre>
<code>{webhook_ini}</code>
</pre>
<Text element="h5">To set up public access to a local machine, consider ngrok</Text>
<pre>
<code>{ngrok_example}</code>
</pre>
</Alert>
</Collapse>
</>
);
}
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { Page } from 'app/core/components/Page/Page';
import { SetupWarnings } from '../SetupWarnings';
import { StatusAlerts } from '../Setup/StatusAlerts';
import { useGetFrontendSettingsQuery } from '../api';
import { PROVISIONING_URL } from '../constants';
@@ -37,7 +37,7 @@ export default function MigrateToProvisioningPage() {
pageNav={{ text: 'Migrate to Provisioning', subTitle: 'Manage this instance from provisioning' }}
>
<Page.Contents>
<SetupWarnings />
<StatusAlerts />
<ProvisioningWizard />
</Page.Contents>
</Page>
@@ -0,0 +1,56 @@
import { CodeEditor, ClipboardButton, useTheme2 } from '@grafana/ui';
import { css } from '@emotion/css';
interface Props {
code: string;
copyCode?: boolean;
}
export const CodeBlock = ({ code, copyCode = true }: Props) => {
const theme = useTheme2();
// Calculate content size
const lineCount = code.split('\n').length;
const estimatedHeight = lineCount * 24; // 24px per line
const minHeight = 42; // Minimum height for one line
const useMinHeight = estimatedHeight <= minHeight;
const height = useMinHeight ? `${minHeight}px` : `${Math.min(estimatedHeight, 300)}px`;
const styles = {
container: css`
position: relative;
margin: ${theme.spacing(2)} 0;
border: 1px solid ${theme.colors.border.medium};
min-height: ${minHeight}px;
`,
copyButton: css`
position: absolute;
top: ${theme.spacing(1)};
right: ${theme.spacing(1)};
z-index: 1;
`,
};
return (
<div className={styles.container}>
{copyCode && (
<ClipboardButton className={styles.copyButton} variant="secondary" size="sm" icon="copy" getText={() => code} />
)}
<CodeEditor
value={code}
language="ini"
showLineNumbers={false}
showMiniMap={false}
height={height}
readOnly={true}
monacoOptions={{
scrollBeyondLastLine: false,
scrollbar: {
vertical: useMinHeight ? 'hidden' : 'auto',
horizontal: 'auto',
},
}}
/>
</div>
);
};
@@ -11,7 +11,7 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
{
path: PROVISIONING_URL,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "SetupWarningPage"*/ 'app/features/provisioning/SetupWarningPage')
() => import(/* webpackChunkName: "SetupPage"*/ 'app/features/provisioning/Setup/SetupPage')
),
},
];
@@ -24,6 +24,12 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "RepositoryListPage"*/ 'app/features/provisioning/RepositoryListPage')
),
},
{
path: PROVISIONING_URL + '/setup',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "SetupPage"*/ 'app/features/provisioning/Setup/SetupPage')
),
},
{
path: PROVISIONING_URL + '/migrate',
component: SafeDynamicImport(