Provisioning: Split active and finished jobs (#103351)

pull/103373/head
Alex Khomenko 4 months ago committed by GitHub
parent d6ec8ab8b1
commit edefc80c2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      pkg/registry/apis/provisioning/jobs/driver.go
  2. 16
      public/app/features/provisioning/Job/ActiveJobStatus.tsx
  3. 76
      public/app/features/provisioning/Job/FinishedJobStatus.tsx
  4. 78
      public/app/features/provisioning/Job/JobContent.tsx
  5. 164
      public/app/features/provisioning/Job/JobStatus.tsx
  6. 31
      public/app/features/provisioning/Job/hooks.ts
  7. 45
      public/app/features/provisioning/Repository/RepositoryLink.tsx
  8. 5
      public/locales/en-US/grafana.json

@ -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)

@ -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 <JobContent job={job} isFinishedJob={false} />;
}

@ -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<typeof setTimeout>;
if (shouldRetry) {
hasRetried.current = true;
timeoutId = setTimeout(() => {
finishedQuery.refetch();
}, 1000);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [finishedQuery, job]);
if (retryFailed) {
return (
<Alert severity="error" title={t('provisioning.job-status.no-job-found', 'No job found')}>
<Trans i18nKey="provisioning.job-status.no-job-found-message">
The job may have been deleted or could not be retrieved. Cancel the current process and start again.
</Trans>
</Alert>
);
}
if (!job || finishedQuery.isLoading || finishedQuery.isFetching) {
return (
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
<Spinner size={24} />
<Text element="h4" color="secondary">
<Trans i18nKey="provisioning.job-status.loading-finished-job">Loading finished job...</Trans>
</Text>
</Stack>
);
}
return <JobContent job={job} isFinishedJob={true} />;
}

@ -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 (
<Alert
severity="success"
title={t('provisioning.job-status.status.title-job-completed-successfully', 'Job completed successfully')}
/>
);
case 'error':
return (
<Alert
severity="error"
title={t('provisioning.job-status.status.title-error-running-job', 'Error running job')}
>
{message}
</Alert>
);
}
return (
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
{['working', 'pending'].includes(state ?? '') && <Spinner size={24} />}
<Text element="h4" color="secondary">
{message ?? state ?? ''}
</Text>
</Stack>
);
};
return (
<Stack direction="column" gap={2}>
<Stack direction="column" gap={2}>
{getStatusDisplay()}
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
<ProgressBar progress={progress} />
</Stack>
{isFinishedJob && summary && (
<Stack direction="column" gap={2}>
<Text variant="h3">
<Trans i18nKey="provisioning.job-status.summary">Summary</Trans>
</Text>
<JobSummary summary={summary} />
</Stack>
)}
{state === 'success' ? (
<RepositoryLink name={job.metadata?.labels?.repository} />
) : (
<ControlledCollapse label={t('provisioning.job-status.label-view-details', 'View details')} isOpen={false}>
<pre>{JSON.stringify(job, null, 2)}</pre>
</ControlledCollapse>
)}
</Stack>
</Stack>
);
}

@ -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 (
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
<Spinner size={24} />
<Text element="h4" weight="bold">
<Text element="h4" color="secondary">
<Trans i18nKey="provisioning.job-status.starting">Starting...</Trans>
</Text>
</Stack>
);
}
const status = () => {
switch (job.status?.state) {
case 'success':
return (
<Alert
severity="success"
title={t('provisioning.job-status.status.title-job-completed-successfully', 'Job completed successfully')}
/>
);
case 'error':
return (
<Alert
severity="error"
title={t('provisioning.job-status.status.title-error-running-job', 'error running job')}
>
{job.status.message}
</Alert>
);
}
if (activeJob) {
return (
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
{!job.status?.progress && <Spinner size={24} />}
<Text element="h4" color="secondary">
{job.status?.message ?? job.status?.state ?? ''}
</Text>
</Stack>
<ActiveJobStatus
job={activeJob}
onStatusChange={onStatusChange}
onRunningChange={onRunningChange}
onErrorChange={onErrorChange}
/>
);
};
return (
<Stack direction="column" gap={2}>
{job.status && (
<Stack direction="column" gap={2}>
{status()}
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
<ProgressBar progress={job.status.progress} />
</Stack>
{job.status.summary && (
<Stack direction="column" gap={2}>
<Text variant="h3">
<Trans i18nKey="provisioning.job-status.summary">Summary</Trans>
</Text>
<JobSummary summary={job.status.summary} />
</Stack>
)}
{job.status.state === 'success' ? (
<RepositoryLink name={job.metadata?.labels?.repository} />
) : (
<ControlledCollapse label={t('provisioning.job-status.label-view-details', 'View details')} isOpen={false}>
<pre>{JSON.stringify(job, null, 2)}</pre>
</ControlledCollapse>
)}
</Stack>
)}
</Stack>
);
}
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 (
<FinishedJobStatus
jobUid={watch.metadata?.uid!}
repositoryName={repoLabel}
onStatusChange={onStatusChange}
onRunningChange={onRunningChange}
onErrorChange={onErrorChange}
/>
);
}
return (
<Stack direction="column" gap={1}>
<Text>
<Trans i18nKey="provisioning.repository-link.grafana-repository">
Grafana and your repository are now in sync.
</Trans>
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
<Spinner size={24} />
<Text element="h4" weight="bold">
<Trans i18nKey="provisioning.job-status.starting">Starting...</Trans>
</Text>
<Stack direction="row" gap={2}>
<LinkButton fill="outline" href={repoHref} icon="external-link-alt" target="_blank" rel="noopener noreferrer">
<Trans i18nKey="provisioning.repository-link.view-repository">View repository</Trans>
</LinkButton>
<LinkButton fill="outline" href={folderHref} icon="folder-open">
<Trans i18nKey="provisioning.repository-link.view-folder">View folder</Trans>
</LinkButton>
</Stack>
</Stack>
);
}

@ -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]);
}

@ -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 (
<Stack direction="column" gap={1}>
<Text>
<Trans i18nKey="provisioning.repository-link.grafana-repository">
Grafana and your repository are now in sync.
</Trans>
</Text>
<Stack direction="row" gap={2}>
<LinkButton fill="outline" href={repoHref} icon="external-link-alt" target="_blank" rel="noopener noreferrer">
<Trans i18nKey="provisioning.repository-link.view-repository">View repository</Trans>
</LinkButton>
<LinkButton fill="outline" href={folderHref} icon="folder-open">
<Trans i18nKey="provisioning.repository-link.view-folder">View folder</Trans>
</LinkButton>
</Stack>
</Stack>
);
}

@ -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"

Loading…
Cancel
Save