POC/Provisioning: Enable sync (#101131)

This commit is contained in:
Alex Khomenko authored and GitHub committed 2025-02-21 15:52:29 +03:00
1 parent 6e7fb03c3a
commit 54d84126ab
13 files changed
+198 -86

No files matched your search

+12 -6
View File
@@ -5766,8 +5766,7 @@ exports[`better eslint`] = {
"public/app/features/provisioning/JobStatus.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"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/provisioning/MigrateToRepository.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@@ -5785,7 +5784,8 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"]
],
"public/app/features/provisioning/RecentJobs.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@@ -5905,10 +5905,12 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "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 />", "9"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
],
"public/app/features/provisioning/Wizard/ProvisioningWizard.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
@@ -5927,7 +5929,11 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "10"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "11"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "12"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "13"]
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "13"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "14"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "15"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "16"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "17"]
],
"public/app/features/provisioning/Wizard/Stepper.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
@@ -55,7 +55,7 @@ export function getDefaultValues(repository?: RepositorySpec): RepositoryFormDat
generateDashboardPreviews: true,
workflows: ['write', 'branch'],
sync: {
enabled: false,
enabled: true,
target: 'instance',
intervalSeconds: 60,
},
@@ -4,6 +4,7 @@ import { Box, ControlledCollapse, Stack, Text, TextLink } from '@grafana/ui';
import ProgressBar from './ProgressBar';
import { useGetRepositoryQuery, useListJobQuery } from './api';
import { getRemoteURL } from './utils/git';
export function JobStatus({ name }: { name: string }) {
const jobQuery = useListJobQuery({ watch: true, fieldSelector: `metadata.name=${name}` });
@@ -18,8 +19,8 @@ export function JobStatus({ name }: { name: string }) {
<Stack direction={'column'} gap={2}>
{job.status && (
<Stack direction="column" gap={2}>
<Text element="p">
{job.status.message} // {job.status.state}
<Text element="p" weight="medium">
{job.status.message ?? ''}
</Text>
<ProgressBar progress={job.status.progress} />
@@ -46,10 +47,15 @@ function RepositoryLink({ name }: RepositoryLinkProps) {
return null;
}
const href = getRemoteURL(repo);
if (!href) {
return null;
}
return (
<Stack direction={'column'}>
<Text>Your dashboards and folders are now in your repository.</Text>
<TextLink external href={repo.spec?.github?.url}>
<TextLink external href={href}>
View repository
</TextLink>
</Stack>
@@ -15,6 +15,7 @@ export function MigrateToRepository({ repo }: Props) {
const { register, formState, handleSubmit } = useForm<MigrateJobOptions>({
defaultValues: {
identifier: true, // Keep URLs the same
history: true,
prefix: '',
},
@@ -4,9 +4,9 @@ import { EmptyState, LinkButton, Alert, Stack, Text } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { SetupWarnings } from './SetupWarnings';
import { MIGRATE_URL } from './constants';
import { NEW_URL, MIGRATE_URL } from './constants';
export default function OnboardingPage() {
export default function OnboardingPage({ legacyStorage }: { legacyStorage?: boolean }) {
return (
<Page
navId="provisioning"
@@ -14,28 +14,42 @@ export default function OnboardingPage() {
>
<Page.Contents>
<SetupWarnings />
<Alert severity="info" title="Setting up this connection could cause a temporary outage">
When you connect your whole instance, dashboards will be unavailable while running the migration. We recommend
warning your users before starting the process.
</Alert>
<EmptyState
variant="call-to-action"
message="Set up your provisioning connection!"
image={<SVG src="public/img/provisioning-empty.svg" width={300} />}
button={
<LinkButton size="lg" icon="plus" href={MIGRATE_URL}>
Connect Grafana to repository
</LinkButton>
}
>
<Stack direction="column" alignItems="center">
<Text>Store and provision your Grafana resources externally by connecting to a repository.</Text>
<Text>We currently support GitHub and local storage.</Text>
<LinkButton fill="text" href="#" icon="external-link-alt">
Learn more
</LinkButton>
</Stack>
</EmptyState>
{legacyStorage ? (
<>
<Alert severity="info" title="Setting up this connection could cause a temporary outage">
When you connect your whole instance, dashboards will be unavailable while running the migration. We
recommend warning your users before starting the process.
</Alert>
<EmptyState
variant="call-to-action"
message="Set up your provisioning connection!"
image={<SVG src="public/img/provisioning-empty.svg" width={300} />}
button={
<LinkButton size="lg" icon="plus" href={MIGRATE_URL}>
Connect Grafana to repository
</LinkButton>
}
>
<Stack direction="column" alignItems="center">
<Text>Store and provision your Grafana resources externally by connecting to a repository.</Text>
<Text>We currently support GitHub and local storage.</Text>
<LinkButton fill="text" href="#" icon="external-link-alt">
Learn more
</LinkButton>
</Stack>
</EmptyState>
</>
) : (
<EmptyState
variant="call-to-action"
message="You haven't created any repository configs yet"
button={
<LinkButton icon="plus" href={NEW_URL} size="lg">
Create repository config
</LinkButton>
}
/>
)}
</Page.Contents>
</Page>
);
@@ -45,7 +45,7 @@ export default function RepositoryListPage() {
}, [deleteAllResult.isSuccess]);
if (!items?.length && !isLoading) {
return <OnboardingPage />;
return <OnboardingPage legacyStorage={settings.data?.legacyStorage} />;
}
const onConfirmDelete = () => {
@@ -42,6 +42,7 @@ import {
} from './api';
import { FileDetails } from './api/types';
import { PROVISIONING_URL } from './constants';
import { getRemoteURL } from './utils/git';
enum TabSelection {
Overview = 'overview',
@@ -253,15 +254,3 @@ function FilesView({ repo }: RepoProps) {
</Stack>
);
}
function getRemoteURL(repo: Repository) {
if (repo.spec?.type === 'github') {
const spec = repo.spec.github;
let url = spec?.url || '';
if (spec?.branch) {
url += `/tree/${spec.branch}`;
}
return url;
}
return undefined;
}
@@ -2,11 +2,12 @@ import { skipToken } from '@reduxjs/toolkit/query/react';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Button, FieldSet, Stack, Text, Switch, Field, Alert } from '@grafana/ui';
import { Alert, Button, FieldSet, Stack, Text, Switch, Field } from '@grafana/ui';
import { JobStatus } from '../JobStatus';
import { useCreateRepositoryMigrateMutation, useGetRepositoryQuery } from '../api';
import { RequestErrorAlert } from './RequestErrorAlert';
import { WizardFormData } from './types';
export interface MigrateStepProps {
@@ -58,6 +59,13 @@ export function MigrateStep({ onMigrationStatusChange }: MigrateStepProps) {
updates will be automatically saved to the repository.
</Text>
{!repositoryName && (
<Alert severity="error" title="Repository name required">
Repository name is required to migrate dashboards. Please complete the repository configuration step first.
</Alert>
)}
<RequestErrorAlert request={migrateQuery} />
<Alert severity="info" title="Note">
Dashboards will be unavailable while running this process.
</Alert>
@@ -35,11 +35,12 @@ export function ProvisioningWizard() {
const navigate = useNavigate();
const [activeStep, setActiveStep] = useState<WizardStep>('connection');
const [completedSteps, setCompletedSteps] = useState<WizardStep[]>([]);
const [migrationSuccess, setMigrationSuccess] = useState(false);
const [stepSuccess, setStepSuccess] = useState(false);
const [submitData, request] = useCreateOrUpdateRepository();
const methods = useForm<WizardFormData>({
defaultValues: {
repository: getDefaultValues(),
migrate: {
history: true,
identifier: false,
@@ -51,19 +52,20 @@ export function ProvisioningWizard() {
useEffect(() => {
if (request.isSuccess) {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: ['Repository settings saved'],
});
if (request.data?.metadata?.name) {
methods.setValue('repositoryName', request.data.metadata.name);
}
if (activeStep === 'repository') {
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: ['Repository settings saved'],
});
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
if (currentStepIndex < steps.length - 1) {
setCompletedSteps([...completedSteps, activeStep]);
setActiveStep(steps[currentStepIndex + 1].id);
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
if (currentStepIndex < steps.length - 1) {
setCompletedSteps((prev) => [...prev, activeStep]);
setActiveStep(steps[currentStepIndex + 1].id);
}
}
} else if (request.isError) {
appEvents.publish({
@@ -71,7 +73,7 @@ export function ProvisioningWizard() {
payload: ['Failed to save repository settings', request.error],
});
}
}, [request.isSuccess, request.isError, request.data, request.error, activeStep, completedSteps, methods]);
}, [request.isSuccess, request.isError, request.data, request.error, activeStep, methods]);
const handleNext = async () => {
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
@@ -90,9 +92,12 @@ export function ProvisioningWizard() {
}
}
setCompletedSteps([...completedSteps, activeStep]);
if (activeStep === 'migrate') {
setCompletedSteps((prev) => [...prev, activeStep]);
}
setActiveStep(steps[currentStepIndex + 1].id);
} else if (activeStep === 'migrate' && migrationSuccess) {
setStepSuccess(false);
} else if (activeStep === 'migrate') {
navigate('/dashboards');
}
};
@@ -101,6 +106,9 @@ export function ProvisioningWizard() {
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
if (currentStepIndex > 0) {
setActiveStep(steps[currentStepIndex - 1].id);
setStepSuccess(false);
// Remove the last completed step when going back
setCompletedSteps((prev) => prev.slice(0, -1));
}
};
@@ -117,19 +125,18 @@ export function ProvisioningWizard() {
<Stepper
steps={steps}
activeStep={activeStep}
onStepChange={() => {}}
visitedSteps={completedSteps}
validationResults={{
connection: { valid: true },
repository: { valid: true },
migrate: { valid: true },
}}
getNextUrl={() => ''}
/>
<div className={styles.content}>
{activeStep === 'connection' && <ConnectionStep />}
{activeStep === 'repository' && <RepositoryStep />}
{activeStep === 'migrate' && <MigrateStep onMigrationStatusChange={setMigrationSuccess} />}
{activeStep === 'repository' && <RepositoryStep request={request} />}
{activeStep === 'migrate' && <MigrateStep onMigrationStatusChange={setStepSuccess} />}
</div>
<Stack gap={2} justifyContent="flex-end">
@@ -140,7 +147,11 @@ export function ProvisioningWizard() {
)}
<Button
onClick={handleNext}
disabled={(activeStep === 'migrate' && !migrationSuccess) || request.isLoading}
disabled={
request.isLoading ||
(activeStep === 'repository' && (request.isLoading || request.isError)) ||
(activeStep === 'migrate' && (!stepSuccess || request.isLoading))
}
icon={request.isLoading ? 'spinner' : undefined}
>
{getButtonText()}
@@ -1,14 +1,41 @@
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Field, FieldSet, Input, MultiCombobox, SecretInput, Switch } from '@grafana/ui';
import { Field, FieldSet, Input, MultiCombobox, SecretInput, Stack, Switch, ControlledCollapse } from '@grafana/ui';
import { getWorkflowOptions } from '../ConfigForm';
import { TokenPermissionsInfo } from '../TokenPermissionsInfo';
import { RequestErrorAlert } from './RequestErrorAlert';
import { WizardFormData } from './types';
export function RepositoryStep() {
interface Props {
request: {
isError: boolean;
error?: unknown;
};
}
const AdvancedSettingsFields = () => {
const { register } = useFormContext<WizardFormData>();
return (
<ControlledCollapse label="Advanced settings" isOpen={true}>
<Field label={'Enable sync'}>
<Switch {...register('repository.sync.enabled')} />
</Field>
<Field label={'Sync interval (seconds)'}>
<Input
{...register('repository.sync.intervalSeconds', { valueAsNumber: true })}
type={'number'}
placeholder={'60'}
/>
</Field>
</ControlledCollapse>
);
};
export function RepositoryStep({ request }: Props) {
const {
register,
control,
@@ -50,7 +77,8 @@ export function RepositoryStep() {
if (type === 'github') {
return (
<FieldSet label="2. Configure repository">
<div>
<Stack direction="column" gap={1}>
<RequestErrorAlert request={request} />
<TokenPermissionsInfo />
<Field
@@ -108,7 +136,8 @@ export function RepositoryStep() {
</Field>
<WorkflowsField />
</div>
<AdvancedSettingsFields />
</Stack>
</FieldSet>
);
}
@@ -116,14 +145,19 @@ export function RepositoryStep() {
if (type === 'local') {
return (
<FieldSet label="2. Configure repository">
<Field label={'Local path'} error={errors.repository?.path?.message} invalid={!!errors.repository?.path}>
<Input
{...register('repository.path', { required: 'This field is required.' })}
placeholder={'/path/to/repo'}
/>
</Field>
<Stack direction="column" gap={2}>
<RequestErrorAlert request={request} />
<WorkflowsField />
<Field label={'Local path'} error={errors.repository?.path?.message} invalid={!!errors.repository?.path}>
<Input
{...register('repository.path', { required: 'This field is required.' })}
placeholder={'/path/to/repo'}
/>
</Field>
<WorkflowsField />
<AdvancedSettingsFields />
</Stack>
</FieldSet>
);
}
@@ -0,0 +1,36 @@
import { Alert } from '@grafana/ui';
import { getMessageFromError } from 'app/core/utils/errors';
interface RequestErrorAlertProps {
request: {
isError: boolean;
error?: unknown;
endpointName?: string;
};
title?: string;
}
function getDefaultTitle(endpointName?: string): string {
switch (endpointName) {
case 'createRepositoryMigrate':
return 'Failed to migrate dashboards';
case 'createOrUpdateRepository':
return 'Failed to save repository settings';
default:
return 'Operation failed';
}
}
export function RequestErrorAlert({ request, title }: RequestErrorAlertProps) {
if (!request.isError) {
return null;
}
const errorTitle = title || getDefaultTitle(request.endpointName);
return (
<Alert severity="error" title={errorTitle}>
{getMessageFromError(request.error)}
</Alert>
);
}
@@ -1,5 +1,4 @@
import { css, cx } from '@emotion/css';
import { MouseEvent } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Icon } from '@grafana/ui';
@@ -13,19 +12,15 @@ export interface Step<T> {
export interface Props<T extends string | number> {
activeStep?: T;
onStepChange: (step: T, event: MouseEvent<HTMLAnchorElement>) => void;
reportId?: string;
visitedSteps?: T[];
steps: Array<Step<T>>;
validationResults: Record<T, ValidationResult>;
getNextUrl: (step: T) => string;
}
export function Stepper<T extends string | number>({
validationResults,
onStepChange,
visitedSteps = [],
getNextUrl,
steps,
activeStep = steps[0]?.id,
}: Props<T>) {
@@ -37,9 +32,9 @@ export function Stepper<T extends string | number>({
{steps.map((step) => {
const isLast = step.id === lastStep.id;
const isActive = step.id === activeStep;
const isVisited = visitedSteps.includes(step.id) || activeStep === lastStep.id;
const hasMissingFields = !isLast && !validationResults[step.id].valid;
const showIndicator = !isActive && !isLast && isVisited;
const isVisited = visitedSteps.includes(step.id);
const hasMissingFields = !validationResults[step.id].valid;
const showIndicator = !isActive && isVisited;
const successField = showIndicator && !hasMissingFields;
const warnField = showIndicator && hasMissingFields;
const itemStyles = cx(styles.item, {
+13 -1
View File
@@ -1,4 +1,4 @@
import { RepositorySpec } from '../api';
import { Repository, RepositorySpec } from '../api';
export function createPRLink(spec?: RepositorySpec, dashboardName?: string, ref?: string, comment?: string) {
if (!spec || spec.type !== 'github' || !ref) {
@@ -21,3 +21,15 @@ export function validateBranchName(branchName?: string) {
return branchName && branchNameRegex.test(branchName!);
}
export function getRemoteURL(repo: Repository) {
if (repo.spec?.type === 'github') {
const spec = repo.spec.github;
let url = spec?.url || '';
if (spec?.branch) {
url += `/tree/${spec.branch}`;
}
return url;
}
return undefined;
}