diff --git a/.betterer.results b/.betterer.results index 047a957ac34..d66c7c881e6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5739,16 +5739,27 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "14"], [0, 0, 0, "No untranslated strings. Wrap text with ", "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 ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + ], + "public/app/features/provisioning/Setup/FeatureList.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/provisioning/Setup/SetupModal.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] + ], + "public/app/features/provisioning/Setup/StatusAlerts.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] ], "public/app/features/provisioning/SyncRepository.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/render.go b/pkg/registry/apis/provisioning/jobs/pullrequest/render.go index 59edefe1e9d..cf4a6b43275 100644 --- a/pkg/registry/apis/provisioning/jobs/pullrequest/render.go +++ b/pkg/registry/apis/provisioning/jobs/pullrequest/render.go @@ -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) { diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 0cb51db5c7e..f9f538f2c28 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -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") diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index b7d1c257be3..95186028fee 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -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) } diff --git a/pkg/registry/apis/provisioning/webhook.go b/pkg/registry/apis/provisioning/webhook.go index f30ae904179..2710e5a43d6 100644 --- a/pkg/registry/apis/provisioning/webhook.go +++ b/pkg/registry/apis/provisioning/webhook.go @@ -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 { diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index b5c484b0db4..26892d2c1dc 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -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 } } }, diff --git a/public/app/features/provisioning/NewRepositoryPage.tsx b/public/app/features/provisioning/NewRepositoryPage.tsx index 8c94a3b20fe..1247453e304 100644 --- a/public/app/features/provisioning/NewRepositoryPage.tsx +++ b/public/app/features/provisioning/NewRepositoryPage.tsx @@ -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.' }} > - + diff --git a/public/app/features/provisioning/OnboardingPage.tsx b/public/app/features/provisioning/OnboardingPage.tsx index 772b7ef0059..181e326ff07 100644 --- a/public/app/features/provisioning/OnboardingPage.tsx +++ b/public/app/features/provisioning/OnboardingPage.tsx @@ -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' }} > - + {legacyStorage ? ( <> diff --git a/public/app/features/provisioning/RepositoryListPage.tsx b/public/app/features/provisioning/RepositoryListPage.tsx index 5bcb69b6e10..7ce68eed910 100644 --- a/public/app/features/provisioning/RepositoryListPage.tsx +++ b/public/app/features/provisioning/RepositoryListPage.tsx @@ -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 ( - + {settings.data?.legacyStorage && ( void; + showSetupButton?: boolean; +} + +export const FeatureCard = ({ feature, onSetup, showSetupButton = true }: Props) => { + const isConfigured = feature.isConfigured; + const iconName = (feature.icon || 'apps') as IconName; + + return ( + + + + + {feature.title} + + + + + + + + {feature.description} + + + {(feature.docsLink || showSetupButton) && ( + + + {showSetupButton && !isConfigured && onSetup && feature.setupSteps.length > 0 && ( + + )} + {feature.docsLink && ( + + )} + + + )} + + {isConfigured ? ( + + ) : ( + + )} + + + ); +}; diff --git a/public/app/features/provisioning/Setup/FeatureList.tsx b/public/app/features/provisioning/Setup/FeatureList.tsx new file mode 100644 index 00000000000..a283d5ecf64 --- /dev/null +++ b/public/app/features/provisioning/Setup/FeatureList.tsx @@ -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([]); + const [showInstructionsModal, setShowModal] = useState(false); + const [activeFeature, setActiveFeature] = useState(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({ + 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 ( + + + All Features + + + {requiredFeatures.map((feature, index) => ( + showModal(basicSetup)} + showSetupButton={!feature.isConfigured} + /> + ))} + {optionalFeatures.map((feature, index) => ( + showModal(feature)} + showSetupButton={!feature.isConfigured} + /> + ))} + + + {showInstructionsModal && activeFeature && ( + + )} + + ); +}; diff --git a/public/app/features/provisioning/Setup/SetupModal.tsx b/public/app/features/provisioning/Setup/SetupModal.tsx new file mode 100644 index 00000000000..4fdc31bbb2d --- /dev/null +++ b/public/app/features/provisioning/Setup/SetupModal.tsx @@ -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 ( + +
+ + {feature.description} + +
+ + + +
+ +
+
+ +
+ + + + {isLastStep ? ( + + ) : ( + + )} + +
+
+ ); +}; + +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)}; + `, +}); diff --git a/public/app/features/provisioning/Setup/SetupPage.tsx b/public/app/features/provisioning/Setup/SetupPage.tsx new file mode 100644 index 00000000000..e5adb704596 --- /dev/null +++ b/public/app/features/provisioning/Setup/SetupPage.tsx @@ -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 ( + + + + + + + ); +} diff --git a/public/app/features/provisioning/Setup/SetupStep.tsx b/public/app/features/provisioning/Setup/SetupStep.tsx new file mode 100644 index 00000000000..527212dc01a --- /dev/null +++ b/public/app/features/provisioning/Setup/SetupStep.tsx @@ -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 ( + <> + + {step?.title} + + {step.description && ( + + {step.description} + + )} + + {step.code && } + + ); +}; diff --git a/public/app/features/provisioning/Setup/Sidebar.tsx b/public/app/features/provisioning/Setup/Sidebar.tsx new file mode 100644 index 00000000000..9045707f912 --- /dev/null +++ b/public/app/features/provisioning/Setup/Sidebar.tsx @@ -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 ( +
+ + {steps.map((step, index) => ( + + ))} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + sidebar: css` + width: 260px; + padding: ${theme.spacing(1)}; + overflow-y: auto; + `, +}); diff --git a/public/app/features/provisioning/Setup/SidebarItem.tsx b/public/app/features/provisioning/Setup/SidebarItem.tsx new file mode 100644 index 00000000000..fa501f270aa --- /dev/null +++ b/public/app/features/provisioning/Setup/SidebarItem.tsx @@ -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; +} + +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 ( + + + + + {step} + + + + ); +}; + +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; + `, +}); diff --git a/public/app/features/provisioning/Setup/StatusAlerts.tsx b/public/app/features/provisioning/Setup/StatusAlerts.tsx new file mode 100644 index 00000000000..ac83f730bf9 --- /dev/null +++ b/public/app/features/provisioning/Setup/StatusAlerts.tsx @@ -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 ( + + All required and optional features are properly configured. Your system is ready to use. + + ); + } + + 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 ( + + Some required features are not properly configured. Please complete the setup for these features to ensure the + system runs properly. + + ); + } + + // Only optional features missing - show info alert + return ( + + Some additional features like Github webhook integration or Previews are not configured. These features can + enhance your experience. + + ); +} diff --git a/public/app/features/provisioning/Setup/types.ts b/public/app/features/provisioning/Setup/types.ts new file mode 100644 index 00000000000..aeacaa6dc02 --- /dev/null +++ b/public/app/features/provisioning/Setup/types.ts @@ -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 = [ + '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`; diff --git a/public/app/features/provisioning/Setup/utils.ts b/public/app/features/provisioning/Setup/utils.ts new file mode 100644 index 00000000000..9e916e2755f --- /dev/null +++ b/public/app/features/provisioning/Setup/utils.ts @@ -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, + }; +}; diff --git a/public/app/features/provisioning/SetupWarningPage.tsx b/public/app/features/provisioning/SetupWarningPage.tsx deleted file mode 100644 index 695c54194db..00000000000 --- a/public/app/features/provisioning/SetupWarningPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Page } from 'app/core/components/Page/Page'; - -import { SetupWarnings } from './SetupWarnings'; - -export default function SetupWarningPage() { - return ( - - - - - - ); -} diff --git a/public/app/features/provisioning/SetupWarnings.tsx b/public/app/features/provisioning/SetupWarnings.tsx deleted file mode 100644 index d0d439d448b..00000000000 --- a/public/app/features/provisioning/SetupWarnings.tsx +++ /dev/null @@ -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 = [ - '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 ( - <> - - {missingFeatures.map((feature) => ( - - Missing required feature toggle: {feature} - - ))} - - - - -
-            {custom_ini}
-          
- - NOTE: the above config is not this machine's config - -
-
- - - - Webhook support requires the server to run on a public URL. -
-            {webhook_ini}
-          
- To set up public access to a local machine, consider ngrok -
-            {ngrok_example}
-          
-
-
- - ); -} diff --git a/public/app/features/provisioning/Wizard/MigrateToProvisioningPage.tsx b/public/app/features/provisioning/Wizard/MigrateToProvisioningPage.tsx index 3b96e946a21..8f15e903371 100644 --- a/public/app/features/provisioning/Wizard/MigrateToProvisioningPage.tsx +++ b/public/app/features/provisioning/Wizard/MigrateToProvisioningPage.tsx @@ -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' }} > - +
diff --git a/public/app/features/provisioning/components/CodeBlock.tsx b/public/app/features/provisioning/components/CodeBlock.tsx new file mode 100644 index 00000000000..ee6f68276b2 --- /dev/null +++ b/public/app/features/provisioning/components/CodeBlock.tsx @@ -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 ( +
+ {copyCode && ( + code} /> + )} + +
+ ); +}; diff --git a/public/app/features/provisioning/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts index 64d6aa32895..502ba5088cc 100644 --- a/public/app/features/provisioning/utils/routes.ts +++ b/public/app/features/provisioning/utils/routes.ts @@ -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(