From cb1d826cddabad4cf7f5514313b070d44a40a1e3 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 8 Feb 2021 17:03:17 +0100 Subject: [PATCH] wip --- plugins/scaffolder/package.json | 5 +- plugins/scaffolder/src/api.ts | 79 +++++++- .../JobStatusModal/JobStatusModal.tsx | 186 +++++++++++++++--- .../components/TemplatePage/TemplatePage.tsx | 76 +++---- .../{useJobPolling.ts => useTaskPolling.ts} | 54 ++--- plugins/scaffolder/src/types.ts | 18 ++ 6 files changed, 313 insertions(+), 105 deletions(-) rename plugins/scaffolder/src/components/hooks/{useJobPolling.ts => useTaskPolling.ts} (52%) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 56beb7b10b..10cc36809a 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index ca52418e92..6b7f1b6181 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -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({ 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) { - 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 { 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 { + 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); + }, + ); + }); + } } diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index d35385ec3b..597e6a243d 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -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 ( {renderTitle()} - {!job ? ( - - ) : ( - (job?.stages ?? []).map(step => ( - - )) - )} + {/* {!task ? ( */} + + {/* ) : ( */} + {/* (task?.spec.steps ?? []).map(step => ( */} + {/* */} + {/* )) */} + {/* )} */} - {job?.status && toCatalogLink && ( + {/* {job?.status && toCatalogLink && ( @@ -89,7 +217,7 @@ export const JobStatusModal = ({ Close - )} + )} */} ); }; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index e1422d4ec1..a3c7ceee12 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -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(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(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 = () => { /> {loading && } - setModalOpen(false)} - /> + {task && ( + setModalOpen(false)} + /> + )} + ) {template && ( void, +export const useTaskPolling = ( + taskId: string | null, + onFinish?: (t: ScaffolderV2Task) => void, pollingInterval = DEFAULT_POLLING_INTERVAL, ) => { const scaffolderApi = useApi(scaffolderApiRef); - const [currentJob, setCurrentJob] = useState(null); + const [currentTask, setCurrentTask] = useState(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; }; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 45672c603d..8cdf22c18b 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -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; +};