From edefc80c2a46ee4fa38ec349399f1dc952174ac1 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Thu, 3 Apr 2025 17:38:12 +0300 Subject: [PATCH] Provisioning: Split active and finished jobs (#103351) --- pkg/registry/apis/provisioning/jobs/driver.go | 2 +- .../provisioning/Job/ActiveJobStatus.tsx | 16 ++ .../provisioning/Job/FinishedJobStatus.tsx | 76 ++++++++ .../features/provisioning/Job/JobContent.tsx | 78 +++++++++ .../features/provisioning/Job/JobStatus.tsx | 164 ++++-------------- public/app/features/provisioning/Job/hooks.ts | 31 ++++ .../Repository/RepositoryLink.tsx | 45 +++++ public/locales/en-US/grafana.json | 5 +- 8 files changed, 282 insertions(+), 135 deletions(-) create mode 100644 public/app/features/provisioning/Job/ActiveJobStatus.tsx create mode 100644 public/app/features/provisioning/Job/FinishedJobStatus.tsx create mode 100644 public/app/features/provisioning/Job/JobContent.tsx create mode 100644 public/app/features/provisioning/Job/hooks.ts create mode 100644 public/app/features/provisioning/Repository/RepositoryLink.tsx diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index 6a5ed6ed2c6..885175b66f1 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -176,7 +176,7 @@ func (d *jobDriver) drive(ctx context.Context) error { } // Save the finished job - err = d.historicJobs.WriteJob(ctx, job) + err = d.historicJobs.WriteJob(ctx, job.DeepCopy()) if err != nil { // We're not going to return this as it is not critical. Not ideal, but not critical. logger.Warn("failed to create historic job", "historic_job", *job, "error", err) diff --git a/public/app/features/provisioning/Job/ActiveJobStatus.tsx b/public/app/features/provisioning/Job/ActiveJobStatus.tsx new file mode 100644 index 00000000000..37a9f4442f4 --- /dev/null +++ b/public/app/features/provisioning/Job/ActiveJobStatus.tsx @@ -0,0 +1,16 @@ +import { Job } from 'app/api/clients/provisioning'; + +import { JobContent } from './JobContent'; +import { useJobStatusEffect } from './hooks'; + +export interface ActiveJobProps { + job: Job; + onStatusChange?: (success: boolean) => void; + onRunningChange?: (isRunning: boolean) => void; + onErrorChange?: (error: string | null) => void; +} + +export function ActiveJobStatus({ job, onStatusChange, onRunningChange, onErrorChange }: ActiveJobProps) { + useJobStatusEffect(job, onStatusChange, onRunningChange, onErrorChange); + return ; +} diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx new file mode 100644 index 00000000000..0f0277f510b --- /dev/null +++ b/public/app/features/provisioning/Job/FinishedJobStatus.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react'; + +import { Alert, Spinner, Stack, Text } from '@grafana/ui'; +import { useGetRepositoryJobsWithPathQuery } from 'app/api/clients/provisioning'; +import { Trans, t } from 'app/core/internationalization'; + +import { JobContent } from './JobContent'; +import { useJobStatusEffect } from './hooks'; + +export interface FinishedJobProps { + jobUid: string; + repositoryName: string; + onStatusChange?: (success: boolean) => void; + onRunningChange?: (isRunning: boolean) => void; + onErrorChange?: (error: string | null) => void; +} + +export function FinishedJobStatus({ + jobUid, + repositoryName, + onStatusChange, + onRunningChange, + onErrorChange, +}: FinishedJobProps) { + const hasRetried = useRef(false); + const finishedQuery = useGetRepositoryJobsWithPathQuery({ + name: repositoryName, + uid: jobUid, + }); + const retryFailed = hasRetried.current && finishedQuery.isError; + + const job = finishedQuery.data; + + useJobStatusEffect(job, onStatusChange, onRunningChange, onErrorChange); + + useEffect(() => { + const shouldRetry = !job && !hasRetried.current && !finishedQuery.isFetching; + let timeoutId: ReturnType; + + if (shouldRetry) { + hasRetried.current = true; + timeoutId = setTimeout(() => { + finishedQuery.refetch(); + }, 1000); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [finishedQuery, job]); + + if (retryFailed) { + return ( + + + The job may have been deleted or could not be retrieved. Cancel the current process and start again. + + + ); + } + + if (!job || finishedQuery.isLoading || finishedQuery.isFetching) { + return ( + + + + Loading finished job... + + + ); + } + + return ; +} diff --git a/public/app/features/provisioning/Job/JobContent.tsx b/public/app/features/provisioning/Job/JobContent.tsx new file mode 100644 index 00000000000..bca97a03c74 --- /dev/null +++ b/public/app/features/provisioning/Job/JobContent.tsx @@ -0,0 +1,78 @@ +import { Alert, ControlledCollapse, Spinner, Stack, Text } from '@grafana/ui'; +import { Job } from 'app/api/clients/provisioning'; +import { Trans, t } from 'app/core/internationalization'; + +import { RepositoryLink } from '../Repository/RepositoryLink'; +import ProgressBar from '../Shared/ProgressBar'; + +import { JobSummary } from './JobSummary'; + +export interface JobContentProps { + job?: Job; + isFinishedJob?: boolean; +} + +export function JobContent({ job, isFinishedJob = false }: JobContentProps) { + if (!job?.status) { + return null; + } + + const { state, message, progress, summary } = job.status; + + const getStatusDisplay = () => { + switch (state) { + case 'success': + return ( + + ); + case 'error': + return ( + + {message} + + ); + } + return ( + + {['working', 'pending'].includes(state ?? '') && } + + {message ?? state ?? ''} + + + ); + }; + + return ( + + + {getStatusDisplay()} + + + + + + {isFinishedJob && summary && ( + + + Summary + + + + )} + {state === 'success' ? ( + + ) : ( + +
{JSON.stringify(job, null, 2)}
+
+ )} +
+
+ ); +} diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index d5e84a06673..1a80c742ab0 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -1,19 +1,9 @@ -import { skipToken } from '@reduxjs/toolkit/query'; -import { useEffect } from 'react'; +import { Spinner, Stack, Text } from '@grafana/ui'; +import { Job, useListJobQuery } from 'app/api/clients/provisioning'; +import { Trans } from 'app/core/internationalization'; -import { Alert, ControlledCollapse, LinkButton, Spinner, Stack, Text } from '@grafana/ui'; -import { - Job, - useGetRepositoryJobsWithPathQuery, - useGetRepositoryQuery, - useListJobQuery, -} from 'app/api/clients/provisioning'; -import { Trans, t } from 'app/core/internationalization'; - -import ProgressBar from '../Shared/ProgressBar'; -import { getRepoHref } from '../utils/git'; - -import { JobSummary } from './JobSummary'; +import { ActiveJobStatus } from './ActiveJobStatus'; +import { FinishedJobStatus } from './FinishedJobStatus'; export interface JobStatusProps { watch: Job; @@ -29,143 +19,51 @@ export function JobStatus({ watch, onStatusChange, onRunningChange, onErrorChang }); const activeJob = activeQuery?.data?.items?.[0]; const repoLabel = watch.metadata?.labels?.['provisioning.grafana.app/repository']; - const finishedQuery = useGetRepositoryJobsWithPathQuery( - activeJob || activeQuery.isUninitialized || activeQuery.isLoading || !repoLabel - ? skipToken - : { - name: repoLabel, - uid: watch.metadata?.uid!, - } - ); - const job = activeJob || finishedQuery.data; + // Only initialize finished query if we've checked active jobs and found none + const activeQueryCompleted = !activeQuery.isUninitialized && !activeQuery.isLoading; + const shouldCheckFinishedJobs = activeQueryCompleted && !activeJob && !!repoLabel; - useEffect(() => { - if (!job && !finishedQuery.isUninitialized) { - finishedQuery.refetch(); - } - }, [finishedQuery, job]); - - useEffect(() => { - if (onStatusChange && job?.status?.state === 'success') { - onStatusChange(true); - if (onRunningChange) { - onRunningChange(false); - } - } - if (onErrorChange && job?.status?.state === 'error') { - onErrorChange(job.status.message ?? t('provisioning.job-status.error-unknown', 'An unknown error occurred')); - if (onRunningChange) { - onRunningChange(false); - } - } - }, [job, onStatusChange, onErrorChange, onRunningChange]); - - if (!job || activeQuery.isLoading) { + if (activeQuery.isLoading) { return ( - + Starting... ); } - const status = () => { - switch (job.status?.state) { - case 'success': - return ( - - ); - case 'error': - return ( - - {job.status.message} - - ); - } + if (activeJob) { return ( - - {!job.status?.progress && } - - {job.status?.message ?? job.status?.state ?? ''} - - + ); - }; - - return ( - - {job.status && ( - - {status()} - - - - - - {job.status.summary && ( - - - Summary - - - - )} - {job.status.state === 'success' ? ( - - ) : ( - -
{JSON.stringify(job, null, 2)}
-
- )} -
- )} -
- ); -} - -type RepositoryLinkProps = { - name?: string; -}; - -function RepositoryLink({ name }: RepositoryLinkProps) { - const repoQuery = useGetRepositoryQuery(name ? { name } : skipToken); - const repo = repoQuery.data; - - if (!repo || repoQuery.isLoading || repo.spec?.type !== 'github' || !repo.spec?.github?.url) { - return null; } - const repoHref = getRepoHref(repo.spec?.github); - const folderHref = repo.spec?.sync.target === 'folder' ? `/dashboards/f/${repo.metadata?.name}` : '/dashboards'; - - if (!repoHref) { - return null; + if (shouldCheckFinishedJobs) { + return ( + + ); } return ( - - - - Grafana and your repository are now in sync. - + + + + Starting... - - - View repository - - - View folder - - ); } diff --git a/public/app/features/provisioning/Job/hooks.ts b/public/app/features/provisioning/Job/hooks.ts new file mode 100644 index 00000000000..04fe54b13f1 --- /dev/null +++ b/public/app/features/provisioning/Job/hooks.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; + +import { Job } from 'app/api/clients/provisioning'; +import { t } from 'app/core/internationalization'; + +// Shared hook for status change effects +export function useJobStatusEffect( + job?: Job, + onStatusChange?: (success: boolean) => void, + onRunningChange?: (isRunning: boolean) => void, + onErrorChange?: (error: string | null) => void +) { + useEffect(() => { + if (!job) { + return; + } + + if (onStatusChange && job.status?.state === 'success') { + onStatusChange(true); + if (onRunningChange) { + onRunningChange(false); + } + } + if (onErrorChange && job.status?.state === 'error') { + onErrorChange(job.status.message ?? t('provisioning.job-status.error-unknown', 'An unknown error occurred')); + if (onRunningChange) { + onRunningChange(false); + } + } + }, [job, onStatusChange, onErrorChange, onRunningChange]); +} diff --git a/public/app/features/provisioning/Repository/RepositoryLink.tsx b/public/app/features/provisioning/Repository/RepositoryLink.tsx new file mode 100644 index 00000000000..bd57dae948e --- /dev/null +++ b/public/app/features/provisioning/Repository/RepositoryLink.tsx @@ -0,0 +1,45 @@ +import { skipToken } from '@reduxjs/toolkit/query'; + +import { LinkButton, Stack, Text } from '@grafana/ui'; +import { useGetRepositoryQuery } from 'app/api/clients/provisioning'; +import { Trans } from 'app/core/internationalization'; + +import { getRepoHref } from '../utils/git'; + +type RepositoryLinkProps = { + name?: string; +}; + +export function RepositoryLink({ name }: RepositoryLinkProps) { + const repoQuery = useGetRepositoryQuery(name ? { name } : skipToken); + const repo = repoQuery.data; + + if (!repo || repoQuery.isLoading || repo.spec?.type !== 'github' || !repo.spec?.github?.url) { + return null; + } + + const repoHref = getRepoHref(repo.spec?.github); + const folderHref = repo.spec?.sync.target === 'folder' ? `/dashboards/f/${repo.metadata?.name}` : '/dashboards'; + + if (!repoHref) { + return null; + } + + return ( + + + + Grafana and your repository are now in sync. + + + + + View repository + + + View folder + + + + ); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e7a50db4398..2b3b79473cb 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5323,9 +5323,12 @@ "job-status": { "error-unknown": "An unknown error occurred", "label-view-details": "View details", + "loading-finished-job": "Loading finished job...", + "no-job-found": "No job found", + "no-job-found-message": "The job may have been deleted or could not be retrieved. Cancel the current process and start again.", "starting": "Starting...", "status": { - "title-error-running-job": "error running job", + "title-error-running-job": "Error running job", "title-job-completed-successfully": "Job completed successfully" }, "summary": "Summary"