Provisioning: Consolidate status display (#108589)

* Provisioning: Centralize wizard errors

* Move alert to shared

* Update pull status

* betterer

* Update warning

* Fix i18n

* Format

* Format[2]

* Extract MessageList

* Add fallback status
This commit is contained in:
Alex Khomenko
2025-07-24 14:59:48 +00:00
committed by GitHub
parent e321ac1075
commit 4def378a61
15 changed files with 207 additions and 108 deletions
-12
View File
@@ -2653,18 +2653,6 @@ exports[`better eslint`] = {
"public/app/features/provisioning/GettingStarted/SidebarItem.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/features/provisioning/Job/RecentJobs.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/features/provisioning/Repository/RepositoryCard.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/features/provisioning/Repository/RepositoryOverview.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"],
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"]
],
"public/app/features/query/components/QueryEditorRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Alert, Spinner, Stack, Text } from '@grafana/ui';
import { Spinner, Stack, Text } from '@grafana/ui';
import { useGetRepositoryJobsWithPathQuery } from 'app/api/clients/provisioning/v0alpha1';
import { useStepStatus } from '../Wizard/StepStatusContext';
@@ -36,15 +36,26 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps)
}
if (finishedQuery.isSuccess && job?.status) {
if (job.status.state === 'error') {
const { state, message, errors } = job.status;
if (state === 'error') {
setStepStatusInfo({
status: 'error',
error: {
title: t('provisioning.job-status.status.title-error-running-job', 'Error running job'),
message: errors?.length ? errors : message,
},
});
} else if (job.status.state === 'success') {
setStepStatusInfo({ status: 'success' });
} else if (job.status.state === 'warning') {
// We treat warnings as success for now, but this could be changed later
} else if (state === 'success') {
setStepStatusInfo({ status: 'success' });
} else if (state === 'warning') {
setStepStatusInfo({
status: 'warning',
warning: {
title: t('provisioning.job-status.status.title-warning-running-job', 'Job completed with warnings'),
message: errors?.length ? errors : message,
},
});
}
}
@@ -56,14 +67,17 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps)
}, [finishedQuery, job, setStepStatusInfo]);
if (retryFailed) {
setStepStatusInfo({ status: 'error' });
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>
);
setStepStatusInfo({
status: 'error',
error: {
title: t('provisioning.job-status.no-job-found', 'No job found'),
message: t(
'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.'
),
},
});
return null;
}
if (!job || finishedQuery.isLoading || finishedQuery.isFetching) {
@@ -1,9 +1,12 @@
import { useEffect, useRef } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Alert, ControlledCollapse, Spinner, Stack, Text } from '@grafana/ui';
import { ControlledCollapse, Spinner, Stack, Text } from '@grafana/ui';
import { Job } from 'app/api/clients/provisioning/v0alpha1';
import { RepositoryLink } from '../Repository/RepositoryLink';
import ProgressBar from '../Shared/ProgressBar';
import { useStepStatus } from '../Wizard/StepStatusContext';
import { JobSummary } from './JobSummary';
@@ -13,6 +16,9 @@ export interface JobContentProps {
}
export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
const { setStepStatusInfo } = useStepStatus();
const errorSetRef = useRef(false);
if (!job?.status) {
return null;
}
@@ -20,48 +26,60 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
const { state, message, progress, summary, errors } = job.status;
const repoName = job.metadata?.labels?.['provisioning.grafana.app/repository'];
const getStatusDisplay = () => {
// Update step status based on job state
useEffect(() => {
if (!state) {
return;
}
switch (state) {
case 'success':
return (
<Alert
severity="success"
title={t('provisioning.job-status.status.title-job-completed-successfully', 'Job completed successfully')}
/>
);
setStepStatusInfo({ status: 'success' });
break;
case 'warning':
return (
<Alert
severity="warning"
title={t('provisioning.job-status.status.title-warning-running-job', 'Job completed with warnings')}
>
{errors?.length ? errors?.join('\n') : message}
</Alert>
);
if (!errorSetRef.current) {
setStepStatusInfo({
status: 'warning',
warning: {
title: t('provisioning.job-status.status.title-warning-running-job', 'Job completed with warnings'),
message: errors?.length ? errors : message,
},
});
errorSetRef.current = true;
}
break;
case 'error':
return (
<Alert
severity="error"
title={t('provisioning.job-status.status.title-error-running-job', 'Error running job')}
>
{errors?.length ? errors?.join('\n') : message}
</Alert>
);
if (!errorSetRef.current) {
setStepStatusInfo({
status: 'error',
error: {
title: t('provisioning.job-status.status.title-error-running-job', 'Error running job'),
message: errors?.length ? errors : message,
},
});
errorSetRef.current = true;
}
break;
case 'working':
case 'pending':
setStepStatusInfo({ status: 'running' });
break;
default:
break;
}
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>
);
};
}, [state, message, errors, setStepStatusInfo]);
return (
<Stack direction="column" gap={2}>
<Stack direction="column" gap={2}>
{getStatusDisplay()}
{['working', 'pending'].includes(state ?? '') && (
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
<Spinner size={24} />
<Text element="h4" color="secondary">
{message ?? state ?? t('provisioning.job-status.starting', 'Starting...')}
</Text>
</Stack>
)}
{state && !['success', 'error'].includes(state) && (
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
<ProgressBar progress={progress ?? 0} />
@@ -1,4 +1,4 @@
import { Trans } from '@grafana/i18n';
import { Trans, t } from '@grafana/i18n';
import { Spinner, Stack, Text } from '@grafana/ui';
import { Job, useListJobQuery } from 'app/api/clients/provisioning/v0alpha1';
@@ -36,7 +36,12 @@ export function JobStatus({ watch }: JobStatusProps) {
}
if (activeQuery.isError) {
setStepStatusInfo({ status: 'error', error: 'Error fetching active job' });
setStepStatusInfo({
status: 'error',
error: {
title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'),
},
});
return null;
}
@@ -2,10 +2,11 @@ import { useMemo } from 'react';
import { intervalToAbbreviatedDurationString, TraceKeyValuePair } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Badge, Box, Card, Icon, InteractiveTable, Spinner, Stack, Text } from '@grafana/ui';
import { Alert, Badge, Box, Card, InteractiveTable, Spinner, Stack, Text } from '@grafana/ui';
import { Job, Repository, SyncStatus } from 'app/api/clients/provisioning/v0alpha1';
import KeyValuesTable from 'app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable';
import { ProvisioningAlert } from '../Shared/ProvisioningAlert';
import { useRepositoryAllJobs } from '../hooks/useRepositoryAllJobs';
import { formatTimestamp } from '../utils/time';
@@ -126,21 +127,7 @@ function ExpandedRow({ row }: ExpandedRowProps) {
<KeyValuesTable data={data} />
</Stack>
)}
{hasErrors && (
<Stack direction="column">
{row.status?.errors?.map(
(error, index) =>
error.trim() && (
<Alert key={index} severity="error" title={t('provisioning.expanded-row.title-error', 'Error')}>
<Stack alignItems="center" gap={1}>
<Icon name="exclamation-circle" size="sm" />
{error}
</Stack>
</Alert>
)
)}
</Stack>
)}
{hasErrors && <ProvisioningAlert error={{ message: row.status?.errors }} />}
{hasSummary && (
<Stack direction="column" gap={2}>
<Text variant="body" color="secondary">
@@ -212,7 +199,7 @@ export function RecentJobs({ repo }: Props) {
}
return (
<Card>
<Card noMargin>
<Card.Heading>
<Trans i18nKey="provisioning.recent-jobs.jobs">Jobs</Trans>
</Card.Heading>
@@ -58,7 +58,7 @@ export function RepositoryCard({ repository }: Props) {
};
return (
<Card key={name}>
<Card noMargin key={name}>
<Card.Figure>
<Icon name={getRepositoryIcon()} size="xxl" />
</Card.Figure>
@@ -6,6 +6,7 @@ import { Box, Card, CellProps, Grid, InteractiveTable, LinkButton, Stack, Text,
import { Repository, ResourceCount } from 'app/api/clients/provisioning/v0alpha1';
import { RecentJobs } from '../Job/RecentJobs';
import { MessageList } from '../Shared/MessageList';
import { formatTimestamp } from '../utils/time';
import { CheckRepository } from './CheckRepository';
@@ -51,7 +52,7 @@ export function RepositoryOverview({ repo }: { repo: Repository }) {
<Stack direction="column" gap={2}>
<Grid columns={columns} gap={2}>
<div className={styles.cardContainer}>
<Card className={styles.card}>
<Card noMargin className={styles.card}>
<Card.Heading>
<Trans i18nKey="provisioning.repository-overview.resources">Resources</Trans>
</Card.Heading>
@@ -73,7 +74,7 @@ export function RepositoryOverview({ repo }: { repo: Repository }) {
</div>
{repo.status?.health && (
<div className={styles.cardContainer}>
<Card className={styles.card}>
<Card noMargin className={styles.card}>
<Card.Heading>
<Trans i18nKey="provisioning.repository-overview.health">Health</Trans>
</Card.Heading>
@@ -129,7 +130,7 @@ export function RepositoryOverview({ repo }: { repo: Repository }) {
</div>
)}
<div className={styles.cardContainer}>
<Card className={styles.card}>
<Card className={styles.card} noMargin>
<Card.Heading>
<Trans i18nKey="provisioning.repository-overview.pull-status">Pull status</Trans>
</Card.Heading>
@@ -192,13 +193,7 @@ export function RepositoryOverview({ repo }: { repo: Repository }) {
</Text>
</div>
<div className={styles.valueColumn}>
<Stack gap={1}>
{status.sync.message.map((msg, idx) => (
<Text key={idx} variant="body">
{msg}
</Text>
))}
</Stack>
<MessageList messages={status.sync.message} variant="body" />
</div>
</>
)}
@@ -211,7 +206,7 @@ export function RepositoryOverview({ repo }: { repo: Repository }) {
</div>
{repo.status?.webhook && (
<div className={styles.cardContainer}>
<Card className={styles.card}>
<Card noMargin className={styles.card}>
<Card.Heading>
<Trans i18nKey="provisioning.repository-overview.webhook">Webhook</Trans>
</Card.Heading>
@@ -0,0 +1,28 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Text, useStyles2 } from '@grafana/ui';
interface MessageListProps {
messages: string[];
variant?: 'body' | 'bodySmall';
}
export function MessageList({ messages, variant }: MessageListProps) {
const styles = useStyles2(getStyles);
return (
<ul className={styles.messageList}>
{messages.map((msg, index) => (
<li key={index}>{variant ? <Text variant={variant}>{msg}</Text> : msg}</li>
))}
</ul>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
messageList: css({
margin: 0,
paddingLeft: theme.spacing(3),
}),
});
@@ -0,0 +1,51 @@
import { t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
import { ProvisioningErrorInfo } from '../types';
import { MessageList } from './MessageList';
interface ProvisioningAlertProps {
error?: string | ProvisioningErrorInfo;
warning?: string | ProvisioningErrorInfo;
}
const getTitle = (alert: string | ProvisioningErrorInfo, isWarning = false) => {
if (typeof alert === 'string') {
return alert;
}
if (isWarning) {
return alert.title || t('provisioning.warning-title-default', 'Warning');
} else {
return alert.title || t('provisioning.error-title-default', 'Error');
}
};
const getMessage = (alert: string | ProvisioningErrorInfo) => {
if (typeof alert === 'string' || !alert.message) {
return null;
}
if (Array.isArray(alert.message)) {
return <MessageList messages={alert.message} />;
}
return alert.message;
};
export function ProvisioningAlert({ error, warning }: ProvisioningAlertProps) {
const alertData = error || warning;
const isWarning = Boolean(warning);
const severity = isWarning ? 'warning' : 'error';
if (!alertData) {
return null;
}
return (
<Alert severity={severity} title={getTitle(alertData, isWarning)}>
{getMessage(alertData)}
</Alert>
);
}
@@ -30,7 +30,7 @@ export function RepositoryList({ items }: Props) {
<ConnectRepositoryButton items={items} />
</Stack>
)}
<Stack direction={'column'}>
<Stack direction={'column'} gap={2}>
{filteredItems.length ? (
filteredItems.map((item) => <RepositoryCard key={item.metadata?.name} repository={item} />)
) : (
@@ -6,11 +6,12 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getAppEvents, isFetchError } from '@grafana/runtime';
import { Alert, Box, Button, Stack, Text, useStyles2 } from '@grafana/ui';
import { Box, Button, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { getDefaultValues } from '../Config/defaults';
import { ProvisioningAlert } from '../Shared/ProvisioningAlert';
import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { dataToSpec } from '../utils/data';
@@ -64,7 +65,8 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { stepStatusInfo, setStepStatusInfo, isStepSuccess, isStepRunning, hasStepError } = useStepStatus();
const { stepStatusInfo, setStepStatusInfo, isStepSuccess, isStepRunning, hasStepError, hasStepWarning } =
useStepStatus();
const { data } = useGetFrontendSettingsQuery();
const isLegacyStorage = Boolean(data?.legacyStorage);
@@ -250,8 +252,8 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
setIsSubmitting(false);
}
} else {
// only proceed if the job was successful
if (isStepSuccess) {
// proceed if the job was successful or had warnings
if (isStepSuccess || hasStepWarning) {
handleNext();
}
}
@@ -262,9 +264,9 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
if (activeStep !== 'connection' && hasStepError) {
return true;
}
// Synchronize step requires success to proceed
// Synchronize step requires success or warning to proceed
if (activeStep === 'synchronize') {
return !isStepSuccess; // Disable next button if the step is not successful
return !(isStepSuccess || hasStepWarning); // Disable next button if the step is not successful or has warnings
}
return isSubmitting || isCancelling || isStepRunning || isCreatingSkipJob;
};
@@ -283,9 +285,8 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
</Text>
</Box>
{hasStepError && 'error' in stepStatusInfo && stepStatusInfo.error && (
<Alert severity="error" title={stepStatusInfo.error} />
)}
{hasStepError && 'error' in stepStatusInfo && <ProvisioningAlert error={stepStatusInfo.error} />}
{hasStepWarning && 'warning' in stepStatusInfo && <ProvisioningAlert warning={stepStatusInfo.warning} />}
<div className={styles.content}>
{activeStep === 'connection' && <ConnectStep />}
@@ -11,6 +11,7 @@ interface StepStatusContextData {
// Computed status checks
hasStepError: boolean;
hasStepWarning: boolean;
isStepRunning: boolean;
isStepSuccess: boolean;
isStepIdle: boolean;
@@ -29,6 +30,7 @@ export const StepStatusProvider = ({ children }: PropsWithChildren) => {
stepStatusInfo,
setStepStatusInfo,
hasStepError: stepStatusInfo.status === 'error',
hasStepWarning: stepStatusInfo.status === 'warning',
isStepRunning: stepStatusInfo.status === 'running',
isStepSuccess: stepStatusInfo.status === 'success',
isStepIdle: stepStatusInfo.status === 'idle',
@@ -1,6 +1,6 @@
import { RepositorySpec, SyncOptions } from 'app/api/clients/provisioning/v0alpha1';
import { RepositoryFormData } from '../types';
import { ProvisioningErrorInfo, RepositoryFormData } from '../types';
export type WizardStep = 'connection' | 'bootstrap' | 'finish' | 'synchronize';
@@ -26,5 +26,7 @@ export interface ModeOption {
subtitle: string;
}
export type StepStatus = 'idle' | 'running' | 'error' | 'success';
export type StepStatusInfo = { status: StepStatus } | { status: 'error'; error: string };
export type StepStatusInfo =
| { status: 'idle' | 'running' | 'success' }
| { status: 'error'; error: string | ProvisioningErrorInfo }
| { status: 'warning'; warning: string | ProvisioningErrorInfo };
@@ -89,3 +89,8 @@ export type HistoryListResponse = {
metadata?: Record<string, unknown>;
items?: HistoryItem[];
};
export interface ProvisioningErrorInfo {
title?: string;
message?: string | string[];
}
+7 -4
View File
@@ -11070,10 +11070,10 @@
"title-instant-updates-requests-webhooks": "Instant updates and pull requests with webhooks.",
"title-visual-previews-in-pull-requests": "Visual previews in pull requests with image rendering"
},
"error-title-default": "Error",
"expanded-row": {
"job-specification": "Job Specification",
"summary": "Summary",
"title-error": "Error"
"summary": "Summary"
},
"features-list": {
"actions": {
@@ -11218,10 +11218,12 @@
"starting": "Starting...",
"status": {
"title-error-running-job": "Error running job",
"title-job-completed-successfully": "Job completed successfully",
"title-warning-running-job": "Job completed with warnings"
},
"summary": "Summary"
"summary": "Summary",
"title": {
"error-fetching-active-job": "Error fetching active job"
}
},
"local": {
"path-description": "Local file system path to the repository",
@@ -11387,6 +11389,7 @@
"go-to": "Go to",
"make-sure": "Make sure to include these permissions"
},
"warning-title-default": "Warning",
"wizard": {
"alert-point-1": "Resources won't be able to be created, edited, or deleted during this process. In the last step, they will disappear.",
"alert-point-2": "Once provisioning is complete, resources will reappear and be managed through external storage.",