This commit is contained in:
Johan Haals
2021-02-08 17:03:17 +01:00
parent 10aa1f4226
commit cb1d826cdd
6 changed files with 313 additions and 105 deletions
+3 -2
View File
@@ -32,6 +32,7 @@
"dependencies": {
"@backstage/catalog-model": "^0.7.1",
"@backstage/core": "^0.6.0",
"@backstage/config": "^0.1.2",
"@backstage/plugin-catalog-react": "^0.0.2",
"@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
@@ -48,13 +49,13 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swr": "^0.3.0"
"swr": "^0.3.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.6.0",
"@backstage/dev-utils": "^0.1.9",
"@backstage/test-utils": "^0.1.6",
"@backstage/catalog-client": "^0.3.5",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+75 -4
View File
@@ -14,13 +14,21 @@
* limitations under the License.
*/
import { createApiRef, DiscoveryApi } from '@backstage/core';
import { JsonObject } from '@backstage/config';
import { createApiRef, DiscoveryApi, Observable } from '@backstage/core';
import ObservableImpl from 'zen-observable';
import { ScaffolderV2Task } from './types';
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
id: 'plugin.scaffolder.service',
description: 'Used to make requests towards the scaffolder backend',
});
type LogEvent = {
type: 'log' | 'completion';
body: JsonObject;
};
export class ScaffolderApi {
private readonly discoveryApi: DiscoveryApi;
@@ -36,7 +44,7 @@ export class ScaffolderApi {
* @param values Parameters for the template, e.g. name, description
*/
async scaffold(templateName: string, values: Record<string, any>) {
const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`;
const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v2/tasks`;
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -52,12 +60,75 @@ export class ScaffolderApi {
}
const { id } = await response.json();
// 859d6b78-7c19-4d1a-abe0-9396ab3bd686
// this.streamLogs({
// taskId: id,
// }).then(observable => {
// console.log('DEBUG: observable =', observable);
// observable.subscribe({
// next: thing => console.warn('next', thing),
// error: thing => console.warn('error', thing),
// complete: () => console.warn('complete'),
// });
// });
return id;
}
async getJob(jobId: string) {
async getTask(taskId: string): Promise<ScaffolderV2Task> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const url = `${baseUrl}/v1/job/${encodeURIComponent(jobId)}`;
const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}`;
return fetch(url).then(x => x.json());
}
streamLogs({
taskId,
after,
}: {
taskId: string;
after?: number;
}): Observable<LogEvent> {
return new ObservableImpl(subscriber => {
const params = new URLSearchParams();
if (after !== undefined) {
params.set('after', String(Number(after)));
}
this.discoveryApi.getBaseUrl('scaffolder').then(
baseUrl => {
const url = `${baseUrl}/v2/tasks/${encodeURIComponent(
taskId,
)}/eventstream`;
const eventSource = new EventSource(url);
eventSource.addEventListener('log', event => {
if (event.data) {
try {
subscriber.next(JSON.parse(event.data));
} catch (ex) {
subscriber.error(ex);
}
}
});
eventSource.addEventListener('completion', event => {
eventSource.close();
if (event.data) {
try {
subscriber.next(JSON.parse(event.data));
} catch (ex) {
subscriber.error(ex);
}
}
subscriber.complete();
});
eventSource.addEventListener('error', event => {
subscriber.error(event);
});
},
error => {
subscriber.error(error);
},
);
});
}
}
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button } from '@backstage/core';
import { Button, Observable, Subscription, useApi } from '@backstage/core';
import {
Button as Action,
Dialog,
@@ -23,64 +23,192 @@ import {
LinearProgress,
} from '@material-ui/core';
import React, { useCallback } from 'react';
import { Job } from '../../types';
import { JobStage } from '../JobStage/JobStage';
import React, { useCallback, useEffect, useReducer } from 'react';
import { scaffolderApiRef } from '../../api';
import { ScaffolderV2Task } from '../../types';
type Props = {
job: Job | null;
task: ScaffolderV2Task | null;
toCatalogLink?: string;
open: boolean;
onModalClose: () => void;
};
type Step = {
id: string;
status: 'open' | 'processing' | 'failed' | 'completed';
};
type ReducerState = {
loading: boolean;
error?: Error;
completed: boolean;
task: ScaffolderV2Task;
step: [{ [stepId in string]: Step }];
};
type ReducerAction =
| {
type: 'INIT';
data: ScaffolderV2Task;
}
| { type: 'EVENT'; data: { body: { stepId: string } } }
| { type: 'COMPLETED' }
| { type: 'ERROR'; data: Error };
function reducer(state: ReducerState, action: ReducerAction) {
const stepId = action.type === 'EVENT' ? action.data.body.stepId : 'global';
const currentStep = state[stepId] ?? { log: [], status: 'open' };
switch (action.type) {
case 'INIT':
return {
...state,
...action.data,
};
case 'EVENT':
return {
...state,
[stepId]: {
...currentStep,
log: [...currentStep.log, event],
},
};
case 'COMPLETED':
return {
...state,
progress: 'done',
};
case 'ERROR':
return {
error: action.error,
loading: false,
completed: false,
};
default:
return state;
}
}
/*
{}
{
id: as'das
spec: {
steps: [
{ id, log: []}
]
}
}
*/
const useTaskEventStream = (taskId: string) => {
// fetch task
const scaffolderApi = useApi(scaffolderApiRef);
const [state, dispatch] = useReducer(reducer, {
loading: true,
});
useEffect(() => {
let didCancel = false;
let subscription: Subscription | undefined;
scaffolderApi.getTask(taskId).then(
task => {
if (didCancel) {
return;
}
dispatch({ type: 'INIT', data: task });
const observable = scaffolderApi.streamLogs({ taskId });
subscription = observable.subscribe({
next: event => dispatch({ type: 'EVENT', data: event }),
error: error => dispatch({ type: 'ERROR', data: error }),
complete: () => dispatch({ type: 'COMPLETED' }),
});
},
error => {
if (!didCancel) {
dispatch({ type: 'ERROR', data: error });
}
},
);
return () => {
didCancel = true;
if (subscription) {
subscription.unsubscribe();
}
};
});
return state;
// subscribe to observable,
// on observer change update the step logs etc + status
// return {
// steps: {
// stepId: {
// log: [],
// status:
// }
// }
// }
};
export const JobStatusModal = ({
job,
task,
toCatalogLink,
open,
onModalClose,
}: Props) => {
const model = useTaskEventStream(task?.id!);
console.warn(model);
const renderTitle = () => {
switch (job?.status) {
case 'COMPLETED':
switch (task?.status) {
case 'completed':
return 'Successfully created component';
case 'FAILED':
case 'failed':
return 'Failed to create component';
default:
return 'Create component';
}
};
const onClose = useCallback(() => {
if (!job) {
if (!task) {
return;
}
// Disallow closing modal if the job is in progress.
if (job.status === 'COMPLETED' || job.status === 'FAILED') {
if (task.status !== 'processing') {
onModalClose();
}
}, [job, onModalClose]);
}, [task, onModalClose]);
return (
<Dialog open={open} onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">{renderTitle()}</DialogTitle>
<DialogContent>
{!job ? (
<LinearProgress />
) : (
(job?.stages ?? []).map(step => (
<JobStage
log={step.log}
name={step.name}
key={step.name}
startedAt={step.startedAt}
endedAt={step.endedAt}
status={step.status}
/>
))
)}
{/* {!task ? ( */}
<LinearProgress />
{/* ) : ( */}
{/* (task?.spec.steps ?? []).map(step => ( */}
{/* <JobStage */}
{/* log={step.log} */}
{/* name={step.name} */}
{/* key={step.name} */}
{/* startedAt={step.startedAt} */}
{/* endedAt={step.endedAt} */}
{/* status={step.status} */}
{/* /> */}
{/* )) */}
{/* )} */}
</DialogContent>
{job?.status && toCatalogLink && (
{/* {job?.status && toCatalogLink && (
<DialogActions>
<Button to={toCatalogLink}>View in catalog</Button>
</DialogActions>
@@ -89,7 +217,7 @@ export const JobStatusModal = ({
<DialogActions>
<Action onClick={onClose}>Close</Action>
</DialogActions>
)}
)} */}
</Dialog>
);
};
@@ -20,6 +20,7 @@ import {
Header,
InfoCard,
Lifecycle,
Observable,
Page,
useApi,
} from '@backstage/core';
@@ -37,7 +38,7 @@ import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRoute } from '../../routes';
import { useJobPolling } from '../hooks/useJobPolling';
import { useTaskPolling } from '../hooks/useTaskPolling';
import { JobStatusModal } from '../JobStatusModal';
import { MultistepJsonForm } from '../MultistepJsonForm';
@@ -91,41 +92,39 @@ export const TemplatePage = () => {
[setFormState, formState],
);
const [jobId, setJobId] = useState<string | null>(null);
const job = useJobPolling(jobId, async jobItem => {
if (!jobItem.metadata.catalogInfoUrl) {
errorApi.post(
new Error(`No catalogInfoUrl returned from the scaffolder`),
);
return;
}
try {
const {
entities: [createdEntity],
} = await catalogApi.addLocation({
target: jobItem.metadata.catalogInfoUrl,
});
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 [taskId, setTaskId] = useState<string | null>(null);
const task = useTaskPolling(taskId, async task => {
console.warn('onFinish is called');
// if (!jobItem.metadata.catalogInfoUrl) {
// errorApi.post(
// new Error(`No catalogInfoUrl returned from the scaffolder`),
// );
// return;
// }
// try {
// const {
// entities: [createdEntity],
// } = await catalogApi.addLocation({
// target: jobItem.metadata.catalogInfoUrl,
// });
// 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 id = await scaffolderApi.scaffold(templateName, formState);
setJobId(id);
setTaskId(id);
setModalOpen(true);
} catch (e) {
errorApi.post(e);
@@ -159,12 +158,15 @@ export const TemplatePage = () => {
/>
<Content>
{loading && <LinearProgress data-testid="loading-progress" />}
<JobStatusModal
job={job}
toCatalogLink={catalogLink}
open={modalOpen}
onModalClose={() => setModalOpen(false)}
/>
{task && (
<JobStatusModal
task={task}
toCatalogLink={catalogLink}
open={modalOpen}
onModalClose={() => setModalOpen(false)}
/>
)}
)
{template && (
<InfoCard title={template.metadata.title} noPadding>
<MultistepJsonForm
@@ -14,49 +14,37 @@
* limitations under the License.
*/
import { useEffect, useState } from 'react';
import { Job } from '../../types';
import { ScaffolderV2Task } from '../../types';
import { useApi } from '@backstage/core';
import { scaffolderApiRef } from '../../api';
import { useInterval } from 'react-use';
const DEFAULT_POLLING_INTERVAL = 1000;
export const useJobPolling = (
jobId: string | null,
onFinish?: (j: Job) => void,
export const useTaskPolling = (
taskId: string | null,
onFinish?: (t: ScaffolderV2Task) => void,
pollingInterval = DEFAULT_POLLING_INTERVAL,
) => {
const scaffolderApi = useApi(scaffolderApiRef);
const [currentJob, setCurrentJob] = useState<Job | null>(null);
const [currentTask, setCurrentTask] = useState<ScaffolderV2Task | null>(null);
useInterval(async () => {
if (taskId) {
setCurrentTask(await scaffolderApi.getTask(taskId));
}
}, pollingInterval);
useEffect(() => {
const resetCurrentJob = async () => {
if (jobId) {
const job = await scaffolderApi.getJob(jobId);
setCurrentJob(job);
}
};
switch (currentTask?.status) {
case 'failed':
case 'cancelled':
case 'completed':
return onFinish?.(currentTask);
default:
return undefined;
}
}, [currentTask, onFinish]);
resetCurrentJob();
}, [jobId, scaffolderApi]);
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);
}
},
shouldBeRunningInterval ? pollingInterval : null,
);
return currentJob;
return currentTask;
};
+18
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonValue } from '@backstage/config';
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
export type Job = {
@@ -35,3 +36,20 @@ export type Stage = {
startedAt: string;
endedAt?: string;
};
export type ScaffolderV2Step = {
id: string;
name: string;
action: string;
parameters?: { [name: string]: JsonValue };
};
export type ScaffolderV2Task = {
id: string;
spec: {
steps: ScaffolderV2Step[];
};
status: 'failed' | 'completed' | 'processing' | 'open' | 'cancelled';
lastHeartbeatAt: string;
createdAt: string;
};