chore(scaffolder): a little housekeeping and better error handling for the scaffolder frontend.

This commit is contained in:
blam
2020-11-22 02:52:00 +01:00
parent ab4794c702
commit f12a7cd906
4 changed files with 94 additions and 97 deletions
@@ -13,43 +13,48 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Button } from '@backstage/core';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import {
Button as Action,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
LinearProgress,
} from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import { generatePath } from 'react-router-dom';
import React, { useCallback, useState } from 'react';
import { Job } from '../../types';
import { JobStage } from '../JobStage/JobStage';
import { useJobPolling } from './useJobPolling';
type Props = {
onComplete: (job: Job) => void;
jobId: string;
entity: TemplateEntityV1alpha1 | null;
job: Job;
toCatalogLink?: string;
};
export const JobStatusModal = ({ jobId, onComplete, entity }: Props) => {
const job = useJobPolling(jobId);
const [dialogTitle, setDialogTitle] = useState('Creating component...');
export const JobStatusModal = ({ job, toCatalogLink }: Props) => {
const [isOpen, setOpen] = useState(true);
useEffect(() => {
if (job?.status === 'COMPLETED') {
setDialogTitle('Successfully created component');
onComplete(job);
} else if (job?.status === 'FAILED')
setDialogTitle('Failed to create component');
}, [job, onComplete, setDialogTitle]);
const renderTitle = () => {
switch (job?.status) {
case 'COMPLETED':
return 'Successfully created component';
case 'FAILED':
return 'Failed to create component';
default:
return 'Create component';
}
};
const onClose = useCallback(() => {
if (job.status === 'COMPLETED' || job.status === 'FAILED') {
setOpen(false);
}
}, [job]);
return (
<Dialog open fullWidth>
<DialogTitle id="responsive-dialog-title">{dialogTitle}</DialogTitle>
<Dialog open={isOpen} onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">{renderTitle()}</DialogTitle>
<DialogContent>
{!job ? (
<LinearProgress />
@@ -66,16 +71,14 @@ export const JobStatusModal = ({ jobId, onComplete, entity }: Props) => {
))
)}
</DialogContent>
{entity && (
{job?.status && toCatalogLink && (
<DialogActions>
<Button
to={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
>
View in catalog
</Button>
<Button to={toCatalogLink}>View in catalog</Button>
</DialogActions>
)}
{job?.status === 'FAILED' && (
<DialogActions>
<Action onClick={() => setOpen(false)}>Close</Action>
</DialogActions>
)}
</Dialog>
@@ -23,18 +23,22 @@ import {
Page,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import {
catalogApiRef,
entityRoute,
entityRouteParams,
} from '@backstage/plugin-catalog';
import { LinearProgress } from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import React, { useState, useCallback } from 'react';
import { Navigate } from 'react-router';
import { generatePath, Navigate } from 'react-router';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRoute } from '../../routes';
import { Job } from '../../types';
import { JobStatusModal } from '../JobStatusModal';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { useJobPolling } from '../hooks/useJobPolling';
const useTemplate = (
templateName: string,
@@ -81,10 +85,10 @@ export const TemplatePage = () => {
const catalogApi = useApi(catalogApiRef);
const scaffolderApi = useApi(scaffolderApiRef);
const { templateName } = useParams();
const [catalogLink, setCatalogLink] = useState<string | undefined>();
const { template, loading } = useTemplate(templateName, catalogApi);
const [formState, setFormState] = useState({});
const [modalOpen, setModalOpen] = useState(false);
const handleFormReset = () => setFormState({});
const handleChange = useCallback(
(e: IChangeEvent) => setFormState({ ...formState, ...e.formData }),
@@ -92,35 +96,42 @@ export const TemplatePage = () => {
);
const [jobId, setJobId] = useState<string | null>(null);
const handleCreate = async () => {
try {
const job = await scaffolderApi.scaffold(templateName, formState);
setJobId(job);
} catch (e) {
errorApi.post(e);
}
};
const [entity, setEntity] = React.useState<TemplateEntityV1alpha1 | null>(
null,
);
const handleCreateComplete = async (job: Job) => {
const job = useJobPolling(jobId, async job => {
if (!job.metadata.catalogInfoUrl) {
errorApi.post(
new Error(
`Failed to find catalog-info.yaml file in ${job.metadata.remoteUrl}.`,
),
new Error(`No catalogInfoUrl returned from the scaffolder`),
);
return;
}
const {
entities: [createdEntity],
} = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl });
try {
const {
entities: [createdEntity],
} = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl });
setEntity((createdEntity as any) as TemplateEntityV1alpha1);
const resolvedPath = generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(createdEntity),
);
setCatalogLink(resolvedPath);
} catch (ex) {
errorApi.post(
new Error(
`Something went wrong trying to add the new 'catalog-info.yaml' to the catalog`,
),
);
}
});
const handleCreate = async () => {
try {
const jobId = await scaffolderApi.scaffold(templateName, formState);
setJobId(jobId);
setModalOpen(true);
} catch (e) {
errorApi.post(e);
}
};
if (!loading && !template) {
@@ -150,13 +161,7 @@ export const TemplatePage = () => {
/>
<Content>
{loading && <LinearProgress data-testid="loading-progress" />}
{jobId && (
<JobStatusModal
onComplete={handleCreateComplete}
jobId={jobId}
entity={entity}
/>
)}
{modalOpen && <JobStatusModal job={job} toCatalogLink={catalogLink} />}
{template && (
<InfoCard title={template.metadata.title} noPadding>
<MultistepJsonForm
@@ -17,46 +17,33 @@ import { useState, useEffect } from 'react';
import { Job } from '../../types';
import { useApi } from '@backstage/core';
import { scaffolderApiRef } from '../../api';
import { useInterval } from 'react-use';
const DEFAULT_POLLING_INTERVAL = 1000;
const poll = (thunk: () => Promise<void>, ms: number) => {
let shouldStop = false;
(async () => {
while (!shouldStop) {
await thunk();
await new Promise(res => setTimeout(res, ms));
}
})();
return () => {
shouldStop = true;
};
};
export const useJobPolling = (
jobId: string | null,
onFinish?: (j: Job) => void,
pollingInterval = DEFAULT_POLLING_INTERVAL,
) => {
const scaffolderApi = useApi(scaffolderApiRef);
const [job, setJob] = useState<Job | null>(null);
useEffect(() => {
if (!jobId) return () => {};
const stopPolling = poll(async () => {
const nextJobState = await scaffolderApi.getJob(jobId);
if (
nextJobState.status === 'FAILED' ||
nextJobState.status === 'COMPLETED'
) {
stopPolling();
const [currentJob, setCurrentJob] = useState<Job | null>(null);
const shouldBeRunningInterval =
jobId &&
currentJob?.status !== 'COMPLETED' &&
currentJob?.status !== 'FAILED';
useInterval(
async () => {
if (jobId) {
const job = await scaffolderApi.getJob(jobId);
if (job?.status === 'COMPLETED' || job?.status === 'FAILED') {
onFinish?.(job);
}
setCurrentJob(job);
}
setJob(nextJobState);
}, pollingInterval);
return () => {
stopPolling();
};
}, [jobId, setJob, scaffolderApi, pollingInterval]);
},
shouldBeRunningInterval ? pollingInterval : null,
);
return job;
return currentJob;
};
+4 -2
View File
@@ -13,6 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
export type Job = {
id: string;
metadata: {
@@ -21,7 +23,7 @@ export type Job = {
remoteUrl?: string;
catalogInfoUrl?: string;
};
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
status: JobStatus;
stages: Stage[];
error?: Error;
};
@@ -29,7 +31,7 @@ export type Job = {
export type Stage = {
name: string;
log: string[];
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
status: JobStatus;
startedAt: string;
endedAt?: string;
};