[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:
+16
-5
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user