wip
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-33
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user