Unifify logs
This commit is contained in:
@@ -17,16 +17,20 @@
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { createApiRef, DiscoveryApi, Observable } from '@backstage/core';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import { ScaffolderTask } from './types';
|
||||
import { ScaffolderTask, Status } from './types';
|
||||
|
||||
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
|
||||
id: 'plugin.scaffolder.service',
|
||||
description: 'Used to make requests towards the scaffolder backend',
|
||||
});
|
||||
|
||||
type LogEvent = {
|
||||
export type LogEvent = {
|
||||
type: 'log' | 'completion';
|
||||
body: JsonObject;
|
||||
body: {
|
||||
message: string;
|
||||
stepId?: string;
|
||||
status?: Status;
|
||||
};
|
||||
createdAt: string;
|
||||
id: string;
|
||||
taskId: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ import Step from '@material-ui/core/Step';
|
||||
import StepLabel from '@material-ui/core/StepLabel';
|
||||
import StepContent from '@material-ui/core/StepContent';
|
||||
import StepConnector from '@material-ui/core/StepConnector';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import clsx from 'clsx';
|
||||
@@ -42,80 +43,6 @@ import {
|
||||
import LazyLog from 'react-lazylog/build/LazyLog';
|
||||
import { StepButton, StepIconProps } from '@material-ui/core';
|
||||
|
||||
const QontoConnector = withStyles({
|
||||
active: {
|
||||
'& $line': {
|
||||
borderColor: '#784af4',
|
||||
},
|
||||
},
|
||||
completed: {
|
||||
'& $line': {
|
||||
borderColor: '#784af4',
|
||||
},
|
||||
},
|
||||
line: {
|
||||
borderColor: '#eaeaf0',
|
||||
borderTopWidth: 3,
|
||||
borderRadius: 1,
|
||||
},
|
||||
})(StepConnector);
|
||||
|
||||
const useQontoStepIconStyles = makeStyles({
|
||||
root: {
|
||||
color: '#eaeaf0',
|
||||
display: 'flex',
|
||||
height: 22,
|
||||
alignItems: 'center',
|
||||
},
|
||||
active: {
|
||||
color: 'grey',
|
||||
},
|
||||
error: {
|
||||
color: 'red',
|
||||
},
|
||||
circle: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'currentColor',
|
||||
},
|
||||
completed: {
|
||||
color: 'green',
|
||||
zIndex: 1,
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
|
||||
function QontoStepIcon(props: StepIconProps) {
|
||||
const classes = useQontoStepIconStyles();
|
||||
const { active, completed, error } = props;
|
||||
|
||||
const getComponent = () => {
|
||||
if (error) {
|
||||
return <Cancel className={classes.error} />;
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
return <Check className={classes.completed} />;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
return <div className={classes.circle} />;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.root, {
|
||||
[classes.active]: active,
|
||||
[classes.error]: error,
|
||||
})}
|
||||
>
|
||||
{getComponent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
@@ -141,13 +68,11 @@ type Steps = {
|
||||
status: Status;
|
||||
};
|
||||
|
||||
export const TaskStepper = ({ steps }: { steps: Steps[] }) => {
|
||||
export const TaskStatusStepper = memo(({ steps }: { steps: Steps[] }) => {
|
||||
const classes = useStyles();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const handleStep = (step: number) => {
|
||||
setExpandAll(false);
|
||||
setActiveStep(step);
|
||||
};
|
||||
|
||||
@@ -155,104 +80,50 @@ export const TaskStepper = ({ steps }: { steps: Steps[] }) => {
|
||||
const activeIndex = steps.findIndex(step =>
|
||||
['failed', 'processing'].includes(step.status),
|
||||
);
|
||||
setActiveStep(2);
|
||||
setActiveStep(activeIndex);
|
||||
}, [steps]);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Button variant="outlined" onClick={() => setExpandAll(true)}>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button variant="outlined">Retry</Button>
|
||||
<Button variant="outlined">Raw Log</Button>
|
||||
<Stepper activeStep={activeStep} orientation="vertical" nonLinear>
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = step.status === 'completed';
|
||||
const isFailed = step.status === 'failed';
|
||||
// return (
|
||||
// <TaskStep
|
||||
// key={String(index)}
|
||||
// log={step.log.length ? step.log.join('\n') : 'please wait'}
|
||||
// expanded={expandAll}
|
||||
// name={step.name}
|
||||
// isFailed={isFailed}
|
||||
// isCompleted={isCompleted}
|
||||
// handleStep={handleStep}
|
||||
// index={index}
|
||||
// />
|
||||
// );
|
||||
|
||||
return (
|
||||
<Step key={String(index)} expanded>
|
||||
<StepButton onClick={() => {}}>
|
||||
<StepLabel
|
||||
StepIconProps={{ completed: isCompleted, error: isFailed }}
|
||||
StepIconComponent={QontoStepIcon}
|
||||
>
|
||||
{step.name}
|
||||
</StepLabel>
|
||||
</StepButton>
|
||||
|
||||
<StepContent>
|
||||
<div style={{ height: '50vh' }}>
|
||||
<LazyLog
|
||||
extraLines={1}
|
||||
text={step.log.length ? step.log.join('\n') : 'please wait'}
|
||||
/>
|
||||
</div>
|
||||
</StepContent>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TaskLogger = memo(({ log }: { log: string }) => {
|
||||
console.log('rendering my log');
|
||||
return (
|
||||
<div style={{ height: '80vh' }}>
|
||||
<LazyLog text={log} extraLines={1} follow />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TaskActionsBar = () => {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outlined">Retry</Button>
|
||||
<Button variant="outlined">Raw Log</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskStepOptions = {
|
||||
name: string;
|
||||
isCompleted: boolean;
|
||||
isFailed: boolean;
|
||||
expanded: boolean;
|
||||
handleStep: () => void;
|
||||
index: number;
|
||||
log: string;
|
||||
};
|
||||
|
||||
export const TaskStep = memo(
|
||||
({
|
||||
log,
|
||||
name,
|
||||
isCompleted,
|
||||
isFailed,
|
||||
handleStep,
|
||||
index,
|
||||
expanded,
|
||||
}: TaskStepOptions) => {
|
||||
const onClick = React.useCallback(() => handleStep(index), [
|
||||
handleStep,
|
||||
index,
|
||||
]);
|
||||
return (
|
||||
<Step key={String(index)} expanded={expanded}>
|
||||
<StepButton onClick={onClick}>
|
||||
<StepLabel
|
||||
StepIconProps={{ completed: isCompleted, error: isFailed }}
|
||||
StepIconComponent={QontoStepIcon}
|
||||
>
|
||||
{name}
|
||||
</StepLabel>
|
||||
</StepButton>
|
||||
|
||||
<StepContent>
|
||||
<div style={{ height: '50vh' }}>
|
||||
<LazyLog extraLines={1} text={log} />
|
||||
</div>
|
||||
</StepContent>
|
||||
</Step>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const TaskPage = () => {
|
||||
const { taskId } = useParams();
|
||||
const taskStream = useTaskEventStream(taskId);
|
||||
@@ -274,7 +145,19 @@ export const TaskPage = () => {
|
||||
subtitle={`Activity for task: ${taskId}`}
|
||||
/>
|
||||
<Content>
|
||||
<TaskStepper steps={steps} />
|
||||
<TaskActionsBar />
|
||||
<Grid container>
|
||||
<Grid item xs={2}>
|
||||
<TaskStatusStepper steps={steps} />
|
||||
</Grid>
|
||||
<Grid item xs={10}>
|
||||
<TaskLogger
|
||||
log={
|
||||
taskStream.log.length ? taskStream.log.join('\n') : 'loading...'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -89,12 +89,9 @@ export const TemplatePage = () => {
|
||||
[setFormState, formState],
|
||||
);
|
||||
|
||||
const [taskId, setTaskId] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const id = await scaffolderApi.scaffold(templateName, formState);
|
||||
setTaskId(id);
|
||||
navigate(tasks({ taskId: id }));
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
|
||||
@@ -14,13 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useImmerReducer } from 'use-immer';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { scaffolderApiRef } from '../../api';
|
||||
import { ScaffolderTask } from '../../types';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { useEffect } from 'react';
|
||||
import { scaffolderApiRef, LogEvent } from '../../api';
|
||||
import { ScaffolderTask, Status } from '../../types';
|
||||
import { Subscription, useApi } from '@backstage/core';
|
||||
|
||||
export type Status = 'open' | 'processing' | 'failed' | 'completed';
|
||||
type Step = {
|
||||
id: string;
|
||||
status: Status;
|
||||
@@ -37,18 +35,14 @@ export type TaskStream = {
|
||||
steps: { [stepId in string]: Step };
|
||||
};
|
||||
|
||||
type ReducerLogEntry = {
|
||||
createdAt: string;
|
||||
body: { stepId?: string; status?: Status; message: string };
|
||||
};
|
||||
|
||||
type ReducerAction =
|
||||
| {
|
||||
type: 'INIT';
|
||||
data: ScaffolderTask;
|
||||
}
|
||||
| {
|
||||
type: 'LOG';
|
||||
data: {
|
||||
createdAt: string;
|
||||
body: { stepId?: string; status?: Status; message: string };
|
||||
};
|
||||
}
|
||||
| { type: 'INIT'; data: ScaffolderTask }
|
||||
| { type: 'LOGS'; data: ReducerLogEntry[] }
|
||||
| { type: 'COMPLETED' }
|
||||
| { type: 'ERROR'; data: Error };
|
||||
|
||||
@@ -67,28 +61,35 @@ function reducer(draft: TaskStream, action: ReducerAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
case 'LOG': {
|
||||
const stepId = action.data.body.stepId ?? 'global';
|
||||
const currentStep = draft.steps?.[stepId];
|
||||
const logLine = `${action.data.createdAt} ${action.data.body.message}`;
|
||||
case 'LOGS': {
|
||||
const entries = action.data;
|
||||
const logLines = [];
|
||||
for (const entry of entries) {
|
||||
const logLine = `${entry.createdAt} ${entry.body.message}`;
|
||||
logLines.push(logLine);
|
||||
|
||||
draft.log.push(logLine);
|
||||
|
||||
if (
|
||||
action.data.body.status &&
|
||||
action.data.body.status !== currentStep.status
|
||||
) {
|
||||
currentStep.status = action.data.body.status;
|
||||
|
||||
if (currentStep.status === 'processing') {
|
||||
currentStep.startedAt = action.data.createdAt;
|
||||
if (!entry.body.stepId || !draft.steps?.[entry.body.stepId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['cancelled', 'failed', 'completed'].includes(currentStep.status)) {
|
||||
currentStep.endedAt = action.data.createdAt;
|
||||
const currentStep = draft.steps?.[entry.body.stepId];
|
||||
|
||||
if (entry.body.status && entry.body.status !== currentStep.status) {
|
||||
currentStep.status = entry.body.status;
|
||||
|
||||
if (currentStep.status === 'processing') {
|
||||
currentStep.startedAt = entry.createdAt;
|
||||
}
|
||||
|
||||
if (
|
||||
['cancelled', 'failed', 'completed'].includes(currentStep.status)
|
||||
) {
|
||||
currentStep.endedAt = entry.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draft.log.push(...logLines);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,17 +118,11 @@ export const useTaskEventStream = (taskId: string): TaskStream => {
|
||||
log: [],
|
||||
steps: {} as { [stepId in string]: Step },
|
||||
});
|
||||
// const [debouncedState, setDebouncedState] = useState(state);
|
||||
// useDebounce(
|
||||
// () => {
|
||||
// setDebouncedState(state);
|
||||
// },
|
||||
// 1000,
|
||||
// [state],
|
||||
// );
|
||||
|
||||
useEffect(() => {
|
||||
let didCancel = false;
|
||||
let subscription: Subscription | undefined;
|
||||
let logPusher: NodeJS.Timeout | undefined;
|
||||
|
||||
scaffolderApi.getTask(taskId).then(
|
||||
task => {
|
||||
@@ -136,19 +131,40 @@ export const useTaskEventStream = (taskId: string): TaskStream => {
|
||||
}
|
||||
dispatch({ type: 'INIT', data: task });
|
||||
const observable = scaffolderApi.streamLogs({ taskId });
|
||||
|
||||
const collectedLogEvents = new Array<LogEvent>();
|
||||
|
||||
function emitLogs() {
|
||||
if (collectedLogEvents.length) {
|
||||
const logs = collectedLogEvents.splice(
|
||||
0,
|
||||
collectedLogEvents.length,
|
||||
);
|
||||
dispatch({ type: 'LOGS', data: logs });
|
||||
}
|
||||
}
|
||||
|
||||
logPusher = setInterval(emitLogs, 500);
|
||||
|
||||
subscription = observable.subscribe({
|
||||
next: event => {
|
||||
switch (event.type) {
|
||||
case 'log':
|
||||
return dispatch({ type: 'LOG', data: event });
|
||||
return collectedLogEvents.push(event);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled event type ${event.type} in observer`,
|
||||
);
|
||||
}
|
||||
},
|
||||
error: error => dispatch({ type: 'ERROR', data: error }),
|
||||
complete: () => dispatch({ type: 'COMPLETED' }),
|
||||
error: error => {
|
||||
emitLogs();
|
||||
dispatch({ type: 'ERROR', data: error });
|
||||
},
|
||||
complete: () => {
|
||||
emitLogs();
|
||||
dispatch({ type: 'COMPLETED' });
|
||||
},
|
||||
});
|
||||
},
|
||||
error => {
|
||||
@@ -163,9 +179,11 @@ export const useTaskEventStream = (taskId: string): TaskStream => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
if (logPusher) {
|
||||
clearInterval(logPusher);
|
||||
}
|
||||
};
|
||||
}, [scaffolderApi, dispatch, taskId]);
|
||||
|
||||
return state;
|
||||
// return debouncedState;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export type Status = 'open' | 'processing' | 'failed' | 'completed';
|
||||
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
export type Job = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user