From 03a114153f1dd4d8d5216372c585d9d1525c0323 Mon Sep 17 00:00:00 2001 From: luyaguari3 Date: Sun, 31 Oct 2021 12:15:16 -0400 Subject: [PATCH] add workflow jobs viewer --- .changeset/coffee-morning-sunshine.md | 5 + .changeset/orange-apricots-refuse.md | 5 + .../src/SDLCServerClient.ts | 109 ++++- packages/legend-server-sdlc/src/index.ts | 1 + .../editor/side-bar/WorkspaceWorkflows.tsx | 396 ++++++++++++++++-- .../sidebar-state/WorkspaceWorkflowsState.ts | 368 +++++++++++++++- .../editor/side-bar/_workspace-workflows.scss | 74 +++- 7 files changed, 913 insertions(+), 45 deletions(-) create mode 100644 .changeset/coffee-morning-sunshine.md create mode 100644 .changeset/orange-apricots-refuse.md diff --git a/.changeset/coffee-morning-sunshine.md b/.changeset/coffee-morning-sunshine.md new file mode 100644 index 00000000000..33f4e4f085b --- /dev/null +++ b/.changeset/coffee-morning-sunshine.md @@ -0,0 +1,5 @@ +--- +'@finos/legend-server-sdlc': patch +--- + +Add workflow jobs apis to retry/cancel/run jobs as well as to view job logs. diff --git a/.changeset/orange-apricots-refuse.md b/.changeset/orange-apricots-refuse.md new file mode 100644 index 00000000000..1ac13d763d8 --- /dev/null +++ b/.changeset/orange-apricots-refuse.md @@ -0,0 +1,5 @@ +--- +'@finos/legend-studio': patch +--- + +Add workflow jobs viewer and ability to retry/run/cancel individual jobs. diff --git a/packages/legend-server-sdlc/src/SDLCServerClient.ts b/packages/legend-server-sdlc/src/SDLCServerClient.ts index 1224698de34..ea6f0452537 100644 --- a/packages/legend-server-sdlc/src/SDLCServerClient.ts +++ b/packages/legend-server-sdlc/src/SDLCServerClient.ts @@ -28,7 +28,7 @@ import type { CreateVersionCommand } from './models/version/VersionCommands'; import type { ProjectStructureVersion } from './models/configuration/ProjectStructureVersion'; import type { User } from './models/User'; import type { PlainObject, TraceData } from '@finos/legend-shared'; -import { AbstractServerClient } from '@finos/legend-shared'; +import { AbstractServerClient, ContentType } from '@finos/legend-shared'; import type { Entity } from '@finos/legend-model-storage'; import type { CreateProjectCommand, @@ -44,6 +44,7 @@ import type { CommitReviewCommand, CreateReviewCommand, } from './models/review/ReviewCommands'; +import type { WorkflowJob } from './models/workflow/WorkflowJob'; enum SDLC_TRACER_SPAN { IMPORT_PROJECT = 'import project', @@ -343,7 +344,31 @@ export class SDLCServerClient extends AbstractServerClient { projectId: string, workspace: Workspace | undefined, ): string => `${this._adaptiveWorkspace(projectId, workspace)}/workflows`; + private _workflow = ( + projectId: string, + workspace: Workspace | undefined, + workflowId: string, + ): string => + `${this._adaptiveWorkspace(projectId, workspace)}/workflows/${workflowId}`; + private _workflowJobs = ( + projectId: string, + workspace: Workspace | undefined, + workflowId: string, + ): string => `${this._workflow(projectId, workspace, workflowId)}/jobs`; + private _workflowJob = ( + projectId: string, + workspace: Workspace | undefined, + workflowId: string, + workflowJobId: string, + ): string => + `${this._workflow(projectId, workspace, workflowId)}/jobs/${workflowJobId}`; + getWorkflow = ( + projectId: string, + workspace: Workspace | undefined, + workflowId: string, + ): Promise[]> => + this.networkClient.get(this._workflow(projectId, workspace, workflowId)); getWorkflows = ( projectId: string, workspace: Workspace | undefined, @@ -357,6 +382,20 @@ export class SDLCServerClient extends AbstractServerClient { undefined, { status, revisionIds, limit }, ); + getWorkflowJobs = ( + projectId: string, + workspace: Workspace | undefined, + workflowId: string, + status: WorkflowStatus | undefined, + revisionIds: string[] | undefined, + limit: number | undefined, + ): Promise[]> => + this.networkClient.get( + this._workflowJobs(projectId, workspace, workflowId), + undefined, + undefined, + { status, revisionIds, limit }, + ); getWorkflowsByRevision = ( projectId: string, workspace: Workspace | undefined, @@ -368,7 +407,73 @@ export class SDLCServerClient extends AbstractServerClient { undefined, { revisionId }, ); - + getWorkflowJob = ( + projectId: string, + workspace: Workspace | undefined, + workflowJob: WorkflowJob, + ): Promise> => + this.networkClient.get( + `${this._workflowJob( + projectId, + workspace, + workflowJob.workflowId, + workflowJob.id, + )}`, + ); + getWorkflowJobLogs = ( + projectId: string, + workspace: Workspace | undefined, + workflowJob: WorkflowJob, + ): Promise => + this.networkClient.get( + `${this._workflowJob( + projectId, + workspace, + workflowJob.workflowId, + workflowJob.id, + )}/logs`, + {}, + { Accept: ContentType.TEXT_PLAIN }, + ); + cancelWorkflowJob = ( + projectId: string, + workspace: Workspace | undefined, + workflowJob: WorkflowJob, + ): Promise[]> => + this.networkClient.post( + `${this._workflowJob( + projectId, + workspace, + workflowJob.workflowId, + workflowJob.id, + )}/cancel`, + ); + retryWorkflowJob = ( + projectId: string, + workspace: Workspace | undefined, + workflowJob: WorkflowJob, + ): Promise[]> => + this.networkClient.post( + `${this._workflowJob( + projectId, + workspace, + workflowJob.workflowId, + workflowJob.id, + )}/retry`, + ); + runManualWorkflowJob = ( + projectId: string, + workspace: Workspace | undefined, + workflowJob: WorkflowJob, + ): Promise[]> => + this.networkClient.post( + `${this._workflowJob( + projectId, + workspace, + workflowJob.workflowId, + workflowJob.id, + )}/run`, + ); // ------------------------------------------- Entity ------------------------------------------- private _entities = ( diff --git a/packages/legend-server-sdlc/src/index.ts b/packages/legend-server-sdlc/src/index.ts index dad269a1ed3..afe84b23d1f 100644 --- a/packages/legend-server-sdlc/src/index.ts +++ b/packages/legend-server-sdlc/src/index.ts @@ -23,6 +23,7 @@ export * from './models/review/Review'; export * from './models/review/ReviewCommands'; export * from './models/workflow/Workflow'; +export * from './models/workflow/WorkflowJob'; export * from './models/project/Project'; export * from './models/project/ImportReport'; diff --git a/packages/legend-studio/src/components/editor/side-bar/WorkspaceWorkflows.tsx b/packages/legend-studio/src/components/editor/side-bar/WorkspaceWorkflows.tsx index a39099379f8..7dfae82dec2 100644 --- a/packages/legend-studio/src/components/editor/side-bar/WorkspaceWorkflows.tsx +++ b/packages/legend-studio/src/components/editor/side-bar/WorkspaceWorkflows.tsx @@ -16,22 +16,42 @@ import { useEffect } from 'react'; import { observer } from 'mobx-react-lite'; -import { clsx, PanelLoadingIndicator } from '@finos/legend-art'; +import type { TreeData, TreeNodeContainerProps } from '@finos/legend-art'; +import { ContextMenu } from '@finos/legend-art'; +import { MenuContent, MenuContentItem } from '@finos/legend-art'; +import { clsx, PanelLoadingIndicator, TreeView } from '@finos/legend-art'; import { MdRefresh } from 'react-icons/md'; import { formatDistanceToNow } from 'date-fns'; import { FaCircleNotch, FaPauseCircle, FaQuestionCircle, + FaChevronDown, + FaChevronRight, FaBan, FaTimesCircle, FaCheckCircle, } from 'react-icons/fa'; import { STUDIO_TEST_ID } from '../../StudioTestID'; import { flowResult } from 'mobx'; -import { WorkflowStatus } from '@finos/legend-server-sdlc'; +import { WorkflowJobStatus, WorkflowStatus } from '@finos/legend-server-sdlc'; import { useEditorStore } from '../EditorStoreProvider'; -import { useApplicationStore } from '@finos/legend-application'; +import { + EDITOR_LANGUAGE, + useApplicationStore, +} from '@finos/legend-application'; +import type { + WorkflowExplorerTreeNodeData, + WorkflowLogState, + WorkspaceWorkflowsState, +} from '../../../stores/sidebar-state/WorkspaceWorkflowsState'; +import { + WorkflowJobTreeNodeData, + WorkflowTreeNodeData, +} from '../../../stores/sidebar-state/WorkspaceWorkflowsState'; +import { guaranteeType, isNonNullable } from '@finos/legend-shared'; +import { Dialog } from '@material-ui/core'; +import { StudioTextInputEditor } from '../../shared/StudioTextInputEditor'; const getWorkflowStatusIcon = ( workflowStatus: WorkflowStatus, @@ -95,20 +115,330 @@ const getWorkflowStatusIcon = ( } }; +const getWorkflowJobStatusIcon = ( + workflowStatus: WorkflowJobStatus, +): React.ReactNode => { + switch (workflowStatus) { + case WorkflowJobStatus.WAITING: + case WorkflowJobStatus.WAITING_MANUAL: + return ( +
+ +
+ ); + case WorkflowJobStatus.IN_PROGRESS: + return ( +
+ +
+ ); + case WorkflowJobStatus.SUCCEEDED: + return ( +
+ +
+ ); + case WorkflowJobStatus.FAILED: + return ( +
+ +
+ ); + case WorkflowJobStatus.CANCELED: + return ( +
+ +
+ ); + case WorkflowJobStatus.UNKNOWN: + default: + return ( +
+ +
+ ); + } +}; +export const WorkfloJobLogsViewer = observer( + (props: { + workflowState: WorkspaceWorkflowsState; + logState: WorkflowLogState; + }) => { + const { workflowState, logState } = props; + const job = logState.job; + const jobIsInProgress = job.status === WorkflowJobStatus.IN_PROGRESS; + const closeLogViewer = (): void => { + workflowState.setWorkflowJobLogState(undefined); + flowResult(workflowState.refreshWorkflows()).catch( + workflowState.editorStore.applicationStore.alertIllegalUnhandledError, + ); + }; + const refreshLogs = (): void => { + logState.refreshJobLogs(); + }; + const logs = logState.logs; + return ( + +
+
+
{`Logs for ${job.name} #${job.id}`}
+
+ +
+
+
+ +
+
+ +
+
+
+ ); + }, +); +const WorkflowExplorerContextMenu = observer( + ( + props: { + workflowState: WorkspaceWorkflowsState; + node: WorkflowExplorerTreeNodeData; + treeData: TreeData; + }, + ref: React.Ref, + ) => { + const { node, workflowState, treeData } = props; + const retryJob = (): void => { + if (node instanceof WorkflowJobTreeNodeData) { + workflowState.retryJob(node.workflowJob, treeData); + } + }; + const cancelJob = (): void => { + if (node instanceof WorkflowJobTreeNodeData) { + workflowState.cancelJob(node.workflowJob, treeData); + } + }; + const viewLogs = (): void => { + if (node instanceof WorkflowJobTreeNodeData) { + workflowState.viewJobLogs(node.workflowJob); + } + }; + const visitWeburl = (): void => { + if (node instanceof WorkflowJobTreeNodeData) { + workflowState.editorStore.applicationStore.navigator.openNewWindow( + node.workflowJob.webURL, + ); + } else if (node instanceof WorkflowTreeNodeData) { + workflowState.editorStore.applicationStore.navigator.openNewWindow( + node.workflow.webURL, + ); + } + }; + + return ( + + {node instanceof WorkflowJobTreeNodeData && ( + <> + View Logs + Visit Job + {node.workflowJob.status !== WorkflowJobStatus.IN_PROGRESS && ( + Retry Job + )} + {node.workflowJob.status === WorkflowJobStatus.IN_PROGRESS && ( + Cancel Job + )} + + )} + {node instanceof WorkflowTreeNodeData && ( + + Visit Workflow + + )} + + ); + }, + { forwardRef: true }, +); + +const WorkflowTreeNodeContainer: React.FC< + TreeNodeContainerProps< + WorkflowExplorerTreeNodeData, + { + workflowState: WorkspaceWorkflowsState; + treeData: TreeData; + } + > +> = (props) => { + const { node, level, stepPaddingInRem, onNodeSelect } = props; + const { workflowState, treeData } = props.innerProps; + const expandIcon = !(node instanceof WorkflowTreeNodeData) ? ( +
+ ) : node.isOpen ? ( + + ) : ( + + ); + const nodeIcon = + node instanceof WorkflowTreeNodeData + ? getWorkflowStatusIcon(node.workflow.status) + : getWorkflowJobStatusIcon( + guaranteeType(node, WorkflowJobTreeNodeData).workflowJob.status, + ); + const selectNode: React.MouseEventHandler = (event) => onNodeSelect?.(node); + return ( + + } + menuProps={{ elevation: 7 }} + > + + + ); +}; export const WorkspaceWorkflows = observer(() => { const editorStore = useEditorStore(); const applicationStore = useApplicationStore(); - const workspaceWorkflowsState = editorStore.workspaceWorkflowsState; - const isDispatchingAction = workspaceWorkflowsState.isFetchingWorkflows; + const workflowState = editorStore.workspaceWorkflowsState; + const workflowTreeData = workflowState.workflowTreeData; + const isDispatchingAction = workflowState.isExecutingWorkflowRequest; const refresh = applicationStore.guaranteeSafeAction(() => - flowResult(workspaceWorkflowsState.fetchAllWorkspaceWorkflows()), + flowResult(workflowState.refreshWorkflows()), ); + const onNodeSelect = (node: WorkflowExplorerTreeNodeData): void => { + if (workflowTreeData) { + workflowState.onTreeNodeSelect(node, workflowTreeData); + } + }; + + const getChildNodes = ( + node: WorkflowExplorerTreeNodeData, + ): WorkflowExplorerTreeNodeData[] => { + if ( + node.childrenIds && + node instanceof WorkflowTreeNodeData && + workflowTreeData + ) { + return node.childrenIds + .map((id) => workflowTreeData.nodes.get(id)) + .filter(isNonNullable); + } + return []; + }; useEffect(() => { - flowResult(workspaceWorkflowsState.fetchAllWorkspaceWorkflows()).catch( + flowResult(workflowState.fetchAllWorkspaceWorkflows()).catch( applicationStore.alertIllegalUnhandledError, ); - }, [applicationStore, workspaceWorkflowsState]); + }, [applicationStore, workflowState]); return (
@@ -127,7 +457,7 @@ export const WorkspaceWorkflows = observer(() => { isDispatchingAction, }, )} - disabled={isDispatchingAction} + disabled={isDispatchingAction || !workflowTreeData} onClick={refresh} tabIndex={-1} title="Refresh" @@ -147,38 +477,32 @@ export const WorkspaceWorkflows = observer(() => { className="side-bar__panel__header__changes-count" data-testid={STUDIO_TEST_ID.SIDEBAR_PANEL_HEADER__CHANGES_COUNT} > - {workspaceWorkflowsState.workflows.length} + {workflowState.workflows.length}
+ {workflowState.workflowJobLogState && ( + + )} ); diff --git a/packages/legend-studio/src/stores/sidebar-state/WorkspaceWorkflowsState.ts b/packages/legend-studio/src/stores/sidebar-state/WorkspaceWorkflowsState.ts index 67cb681d702..c75af63c4c8 100644 --- a/packages/legend-studio/src/stores/sidebar-state/WorkspaceWorkflowsState.ts +++ b/packages/legend-studio/src/stores/sidebar-state/WorkspaceWorkflowsState.ts @@ -14,35 +14,206 @@ * limitations under the License. */ -import { makeAutoObservable } from 'mobx'; +import type { TreeNodeData, TreeData } from '@finos/legend-art'; +import { makeAutoObservable, observable, action, flowResult } from 'mobx'; import { STUDIO_LOG_EVENT } from '../StudioLogEvent'; import type { EditorStore } from '../EditorStore'; import type { EditorSdlcState } from '../EditorSdlcState'; import type { GeneratorFn, PlainObject } from '@finos/legend-shared'; +import { isNonNullable } from '@finos/legend-shared'; import { assertErrorThrown, LogEvent } from '@finos/legend-shared'; import { Workflow } from '@finos/legend-server-sdlc'; +import { WorkflowJob } from '@finos/legend-server-sdlc'; + +export abstract class WorkflowExplorerTreeNodeData implements TreeNodeData { + isSelected?: boolean | undefined; + isOpen?: boolean | undefined; + id: string; + label: string; + childrenIds: string[] | undefined; + constructor(id: string, label: string) { + this.id = id; + this.label = label; + } +} +export class WorkflowTreeNodeData extends WorkflowExplorerTreeNodeData { + workflow: Workflow; + constructor(workflow: Workflow) { + super(workflow.id, workflow.id); + this.workflow = workflow; + } +} +export class WorkflowJobTreeNodeData extends WorkflowExplorerTreeNodeData { + workflowJob: WorkflowJob; + constructor(workflowJob: WorkflowJob) { + super(workflowJob.id, workflowJob.name); + this.workflowJob = workflowJob; + } +} + +const addWorkflowNodeToTree = ( + workflow: Workflow, + treeData: TreeData, +): WorkflowTreeNodeData => { + const node = new WorkflowTreeNodeData(workflow); + treeData.rootIds.push(node.id); + treeData.nodes.set(node.id, node); + return node; +}; + +const addWorkflowJobNodeToTree = ( + workflowJob: WorkflowJob, + workflowNode: WorkflowTreeNodeData, + treeData: TreeData, +): WorkflowJobTreeNodeData => { + const node = new WorkflowJobTreeNodeData(workflowJob); + if (workflowNode.childrenIds) { + workflowNode.childrenIds.push(node.id); + } else { + workflowNode.childrenIds = [node.id]; + } + + treeData.nodes.set(node.id, node); + return node; +}; + +const getWorkflowExplorerTreeNodeData = ( + workflows: Workflow[], +): TreeData => { + const rootIds: string[] = []; + const nodes = new Map(); + const treeData = { rootIds, nodes }; + workflows.forEach((w) => addWorkflowNodeToTree(w, treeData)); + return treeData; +}; + +const updateWorkflowJobData = ( + workflowJobs: WorkflowJob[], + workflowId: string, + treeData: TreeData, +): void => { + const workflowNode = treeData.nodes.get(workflowId); + if (workflowNode instanceof WorkflowTreeNodeData) { + workflowNode.childrenIds?.forEach((id) => treeData.nodes.delete(id)); + workflowNode.childrenIds = []; + workflowJobs.forEach((job) => + addWorkflowJobNodeToTree(job, workflowNode, treeData), + ); + } +}; + +export class WorkflowLogState { + editorStore: EditorStore; + job: WorkflowJob; + logs: string; + constructor(editorStore: EditorStore, job: WorkflowJob, logs: string) { + makeAutoObservable(this, { + editorStore: false, + job: observable, + logs: observable, + }); + + this.editorStore = editorStore; + this.job = job; + this.logs = logs; + } + + setLogs(val: string): void { + this.logs = val; + } + + setJob(val: WorkflowJob): void { + this.job = val; + } + + *refreshJobLogs(): GeneratorFn { + try { + const job = WorkflowJob.serialization.fromJson( + (yield this.editorStore.sdlcServerClient.getWorkflowJob( + this.editorStore.sdlcState.activeProject.projectId, + this.editorStore.sdlcState.activeWorkspace, + this.job, + )) as PlainObject, + ); + const logs = (yield this.editorStore.sdlcServerClient.getWorkflowJobLogs( + this.editorStore.sdlcState.activeProject.projectId, + this.editorStore.sdlcState.activeWorkspace, + this.job, + )) as string; + this.setJob(job); + this.setLogs(logs); + } catch (error) { + assertErrorThrown(error); + this.editorStore.applicationStore.log.error( + LogEvent.create(STUDIO_LOG_EVENT.SDLC_MANAGER_FAILURE), + error, + ); + this.editorStore.applicationStore.notifyError(error); + } + } +} export class WorkspaceWorkflowsState { editorStore: EditorStore; sdlcState: EditorSdlcState; - isFetchingWorkflows = false; + isExecutingWorkflowRequest = false; workflows: Workflow[] = []; + workflowTreeData: TreeData | undefined; + workflowJobLogState: WorkflowLogState | undefined; constructor(editorStore: EditorStore, sdlcState: EditorSdlcState) { makeAutoObservable(this, { editorStore: false, sdlcState: false, + workflowTreeData: observable, + setWorkflowTreeData: action, + setWorkflowJobLogState: action, + workflowJobLogState: observable, }); this.editorStore = editorStore; this.sdlcState = sdlcState; } + setWorkflowTreeData( + val: TreeData | undefined, + ): void { + this.workflowTreeData = val; + } + + setWorkflowJobLogState(val: WorkflowLogState | undefined): void { + this.workflowJobLogState = val; + } + *fetchAllWorkspaceWorkflows(): GeneratorFn { try { - this.isFetchingWorkflows = true; - // NOTE: this network call can take a while, so we might consider limiting the number of workflows to 10 or so - this.workflows = ( + this.isExecutingWorkflowRequest = true; + const workflows = ( + (yield this.editorStore.sdlcServerClient.getWorkflows( + this.sdlcState.activeProject.projectId, + this.sdlcState.activeWorkspace, + undefined, + undefined, + undefined, + )) as PlainObject[] + ).map((workflow) => Workflow.serialization.fromJson(workflow)); + this.setWorkflowTreeData(getWorkflowExplorerTreeNodeData(workflows)); + } catch (error) { + assertErrorThrown(error); + this.editorStore.applicationStore.log.error( + LogEvent.create(STUDIO_LOG_EVENT.SDLC_MANAGER_FAILURE), + error, + ); + this.editorStore.applicationStore.notifyError(error); + } finally { + this.isExecutingWorkflowRequest = false; + } + } + + *refreshWorkflows(): GeneratorFn { + try { + this.isExecutingWorkflowRequest = true; + const workflows = ( (yield this.editorStore.sdlcServerClient.getWorkflows( this.sdlcState.activeProject.projectId, this.sdlcState.activeWorkspace, @@ -51,6 +222,191 @@ export class WorkspaceWorkflowsState { undefined, )) as PlainObject[] ).map((workflow) => Workflow.serialization.fromJson(workflow)); + const treeData = getWorkflowExplorerTreeNodeData(workflows); + // refetch open nodes + const currentTreeData = this.workflowTreeData; + if (currentTreeData) { + const openNodeIds = Array.from(currentTreeData.nodes.values()) + .filter((n) => n.isOpen) + .map((n) => n.id); + const workflowToJobsMap = new Map(); + yield Promise.all( + workflows + .filter((workflow) => openNodeIds.includes(workflow.id)) + .map((workflow) => + this.editorStore.sdlcServerClient + .getWorkflowJobs( + this.sdlcState.activeProject.projectId, + this.sdlcState.activeWorkspace, + workflow.id, + undefined, + undefined, + undefined, + ) + .then((jobs: PlainObject[]) => + workflowToJobsMap.set( + workflow.id, + jobs.map((x) => WorkflowJob.serialization.fromJson(x)), + ), + ), + ), + ); + workflowToJobsMap.forEach((jobs, workflowId) => + updateWorkflowJobData(jobs, workflowId, treeData), + ); + treeData.nodes.forEach((node) => { + if (openNodeIds.includes(node.id)) { + node.isOpen = true; + } + }); + } + this.setWorkflowTreeData({ ...treeData }); + } catch (error) { + assertErrorThrown(error); + this.editorStore.applicationStore.log.error( + LogEvent.create(STUDIO_LOG_EVENT.SDLC_MANAGER_FAILURE), + error, + ); + this.editorStore.applicationStore.notifyError(error); + } finally { + this.isExecutingWorkflowRequest = false; + } + } + + *refreshWorkflow( + workflowId: string, + treeData: TreeData, + ): GeneratorFn { + const node = treeData.nodes.get(workflowId); + if (node instanceof WorkflowTreeNodeData) { + const workflow = Workflow.serialization.fromJson( + (yield this.editorStore.sdlcServerClient.getWorkflow( + this.sdlcState.activeProject.projectId, + this.sdlcState.activeWorkspace, + workflowId, + )) as PlainObject, + ); + node.workflow = workflow; + } + yield flowResult(this.fetchAllWorkspaceWorkJobs(workflowId, treeData)); + } + + *fetchAllWorkspaceWorkJobs( + workflowId: string, + treeData: TreeData, + ): GeneratorFn { + try { + this.isExecutingWorkflowRequest = true; + const workflowJobs = ( + (yield this.editorStore.sdlcServerClient.getWorkflowJobs( + this.sdlcState.activeProject.projectId, + this.sdlcState.activeWorkspace, + workflowId, + undefined, + undefined, + undefined, + )) as PlainObject[] + ).map((job) => WorkflowJob.serialization.fromJson(job)); + updateWorkflowJobData(workflowJobs, workflowId, treeData); + this.setWorkflowTreeData({ ...treeData }); + } catch (error) { + assertErrorThrown(error); + this.editorStore.applicationStore.log.error( + LogEvent.create(STUDIO_LOG_EVENT.SDLC_MANAGER_FAILURE), + error, + ); + this.editorStore.applicationStore.notifyError(error); + } finally { + this.isExecutingWorkflowRequest = false; + } + } + + *cancelJob( + workflowJob: WorkflowJob, + treeData: TreeData, + ): GeneratorFn { + try { + this.isExecutingWorkflowRequest = true; + (yield this.editorStore.sdlcServerClient.cancelWorkflowJob( + this.sdlcState.activeProject.projectId, + this.sdlcState.activeWorkspace, + workflowJob, + )) as PlainObject[]; + yield flowResult(this.refreshWorkflow(workflowJob.workflowId, treeData)); + } catch (error) { + assertErrorThrown(error); + this.editorStore.applicationStore.log.error( + LogEvent.create(STUDIO_LOG_EVENT.SDLC_MANAGER_FAILURE), + error, + ); + this.editorStore.applicationStore.notifyError(error); + } finally { + this.isExecutingWorkflowRequest = false; + } + } + + *retryJob( + workflowJob: WorkflowJob, + treeData: TreeData, + ): GeneratorFn { + try { + this.isExecutingWorkflowRequest = true; + (yield this.editorStore.sdlcServerClient.retryWorkflowJob( + this.sdlcState.activeProject.projectId, + this.sdlcState.activeWorkspace, + workflowJob, + )) as PlainObject[]; + yield flowResult(this.refreshWorkflow(workflowJob.workflowId, treeData)); + } catch (error) { + assertErrorThrown(error); + this.editorStore.applicationStore.log.error( + LogEvent.create(STUDIO_LOG_EVENT.SDLC_MANAGER_FAILURE), + error, + ); + this.editorStore.applicationStore.notifyError(error); + } finally { + this.isExecutingWorkflowRequest = false; + } + } + + *onTreeNodeSelect( + node: WorkflowExplorerTreeNodeData, + treeData: TreeData, + ): GeneratorFn { + if (node instanceof WorkflowTreeNodeData) { + if (!node.childrenIds) { + yield flowResult( + this.fetchAllWorkspaceWorkJobs(node.workflow.id, treeData), + ); + } + node.isOpen = !node.isOpen; + } + this.setWorkflowTreeData({ ...treeData }); + } + + getChildNodes( + node: WorkflowExplorerTreeNodeData, + treeData: TreeData, + ): WorkflowExplorerTreeNodeData[] { + if (node.childrenIds && node instanceof WorkflowTreeNodeData) { + return node.childrenIds + .map((id) => treeData.nodes.get(id)) + .filter(isNonNullable); + } + return []; + } + + *viewJobLogs(workflowJob: WorkflowJob): GeneratorFn { + try { + this.isExecutingWorkflowRequest = true; + const logs = (yield this.editorStore.sdlcServerClient.getWorkflowJobLogs( + this.sdlcState.activeProject.projectId, + this.sdlcState.activeWorkspace, + workflowJob, + )) as string; + this.setWorkflowJobLogState( + new WorkflowLogState(this.editorStore, workflowJob, logs), + ); } catch (error) { assertErrorThrown(error); this.editorStore.applicationStore.log.error( @@ -59,7 +415,7 @@ export class WorkspaceWorkflowsState { ); this.editorStore.applicationStore.notifyError(error); } finally { - this.isFetchingWorkflows = false; + this.isExecutingWorkflowRequest = false; } } } diff --git a/packages/legend-studio/style/components/editor/side-bar/_workspace-workflows.scss b/packages/legend-studio/style/components/editor/side-bar/_workspace-workflows.scss index 7479734364c..9f56d6c2c5a 100644 --- a/packages/legend-studio/style/components/editor/side-bar/_workspace-workflows.scss +++ b/packages/legend-studio/style/components/editor/side-bar/_workspace-workflows.scss @@ -40,7 +40,6 @@ &__item__link__content__status__indicator { display: inline-flex; - margin-right: 0.5rem; vertical-align: middle; svg { @@ -79,4 +78,77 @@ font-size: 1.2rem; color: var(--color-dark-grey-500); } + + &__explorer { + &__workflow-tree__node__container:hover { + background: var(--color-dark-blue-shade-100); + } + + &__workflow-tree__node__container--selected, + &__workflow-tree__node__container--selected:hover { + background: var(--color-light-blue-450); + } + + &__workflow-tree__node__icon { + width: 4rem; + min-width: 4rem; + } + + &__workflow-tree__node__icon__expand, + &__workflow-tree__node__icon__type { + width: 2rem; + + @include flexHCenter; + } + + &__workflow-tree__node__icon__expand svg { + font-size: 1rem; + } + + &__workflow-tree__node__label { + color: inherit; + } + } +} + +.workspace-workflow-jobs { + &__item__link { + color: var(--color-dark-grey-500); + text-decoration: none; + } + + &__item__link__content { + @include ellipsisTextOverflow; + } + + &__item__link__content__status__indicator { + display: inline-flex; + vertical-align: middle; + + svg { + font-size: 1.5rem; + } + + &--succeeded svg { + color: var(--color-green-100); + } + + &--failed svg { + color: var(--color-red-100); + } + + &--in-progress svg { + animation: spin 1s infinite ease; + color: var(--color-blue-100); + } + + &--suspended svg { + color: var(--color-yellow-400); + } + + &--unknown svg, + &--canceled svg { + color: var(--color-dark-grey-500); + } + } }