diff --git a/plugins/parodos/package.json b/plugins/parodos/package.json index 3cf70c38..13ed6b97 100644 --- a/plugins/parodos/package.json +++ b/plugins/parodos/package.json @@ -45,6 +45,7 @@ "classnames": "^2.3.2", "immer": "^9.0.19", "lodash.get": "^4.4.2", + "lodash.pickby": "^4.6.0", "lodash.set": "^4.3.2", "luxon": "^3.2.1", "mobx": "^5.15.4", @@ -71,6 +72,7 @@ "@testing-library/user-event": "^14.0.0", "@types/lodash.get": "^4.4.7", "@types/lodash.set": "^4.3.7", + "@types/lodash.pickby": "^4.6.7", "@types/node": "*", "cross-fetch": "^3.1.5", "msw": "^0.49.0", diff --git a/plugins/parodos/src/components/ParodosPage/Tabs.tsx b/plugins/parodos/src/components/ParodosPage/Tabs.tsx index d01456a7..7429986a 100644 --- a/plugins/parodos/src/components/ParodosPage/Tabs.tsx +++ b/plugins/parodos/src/components/ParodosPage/Tabs.tsx @@ -49,7 +49,6 @@ export function Tabs(): JSX.Element { {navigationMap[index].icon} - {tabChildren} {notifyIcon && ( )} + {tabChildren} ), diff --git a/plugins/parodos/src/components/markdown/renderers.tsx b/plugins/parodos/src/components/markdown/renderers.tsx index 9c4a1b13..48c5f834 100644 --- a/plugins/parodos/src/components/markdown/renderers.tsx +++ b/plugins/parodos/src/components/markdown/renderers.tsx @@ -195,7 +195,14 @@ export const renderers: Components = { ul: Ul, li: Li, code: Code, - a: props => , + a: props => ( + + ), blockquote: Blockquote, table: StyledTable, thead: props => , diff --git a/plugins/parodos/src/components/workflow/workflowDetail/WorkFlowDetail.tsx b/plugins/parodos/src/components/workflow/workflowDetail/WorkFlowDetail.tsx index d1cb1ea7..c55dc4bf 100644 --- a/plugins/parodos/src/components/workflow/workflowDetail/WorkFlowDetail.tsx +++ b/plugins/parodos/src/components/workflow/workflowDetail/WorkFlowDetail.tsx @@ -1,6 +1,7 @@ import { ParodosPage } from '../../ParodosPage'; import { ContentHeader, + InfoCard, Progress, SupportButton, } from '@backstage/core-components'; @@ -19,13 +20,17 @@ import { } from '../../../models/workflowTaskSchema'; import { useStore } from '../../../stores/workflowStore/workflowStore'; import { fetchApiRef, useApi } from '@backstage/core-plugin-api'; -import { getWorkflowTasksForTopology } from '../../../hooks/getWorkflowDefinitions'; +import { + FirstTaskId, + getWorkflowTasksForTopology, +} from '../../../hooks/getWorkflowDefinitions'; import { assert } from 'assert-ts'; const useStyles = makeStyles(_theme => ({ container: { display: 'flex', flexDirection: 'column', + flex: 1, }, badge: { alignSelf: 'flex-start', @@ -40,9 +45,12 @@ const useStyles = makeStyles(_theme => ({ display: 'grid', minHeight: 0, }, + card: { + height: '100%', + }, })); -export const WorkFlowDetail = () => { +export function WorkFlowDetail(): JSX.Element { const { projectId, executionId } = useParams(); assert(!!projectId, 'no projectId param'); const project = useStore(state => state.getProjectById(projectId)); @@ -60,14 +68,20 @@ export const WorkFlowDetail = () => { useEffect(() => { const updateWorks = (works: WorkStatus[]) => { let needUpdate = false; + // TODO: use immer here after demo const tasks = [...allTasks]; for (const work of works) { if (work.type === 'TASK') { const foundTask = tasks.find(task => task.id === work.name); + if (foundTask && foundTask.status !== work.status) { foundTask.status = work.status; needUpdate = true; } + if (foundTask && work.alertMessage !== foundTask?.alertMessage) { + foundTask.alertMessage = work.alertMessage; + needUpdate = true; + } } else if (work.works) { updateWorks(work.works); } @@ -100,11 +114,13 @@ export const WorkFlowDetail = () => { const taskInterval = setInterval(() => { updateWorksFromApi(); }, 5000); + updateWorksFromApi(); - if (status === 'FAILED') { - clearInterval(taskInterval); - } + // TOOD: review after Demo + // if (status === 'FAILED') { + // clearInterval(taskInterval); + // } return () => clearInterval(taskInterval); }, [ @@ -119,6 +135,17 @@ export const WorkFlowDetail = () => { useEffect(() => { const updateWorkFlowLogs = async () => { + const selected = allTasks.find(task => task.id === selectedTask); + if (selectedTask === FirstTaskId) { + setLog('Start workflow'); + return; + } + + if (selected && selected?.status === 'PENDING') { + setLog('Pending....'); + return; + } + if (selectedTask === '') { setLog(''); return; @@ -138,7 +165,7 @@ export const WorkFlowDetail = () => { updateWorkFlowLogs(); return () => clearInterval(logInterval); - }, [executionId, selectedTask, fetch, workflowsUrl]); + }, [executionId, selectedTask, fetch, workflowsUrl, allTasks]); return ( @@ -152,21 +179,29 @@ export const WorkFlowDetail = () => { Lorem Ipsum - - You are onboarding {project?.name || '...'} project, - running workflow "{workflowName}" (execution ID: {executionId}) - - - - {allTasks.length > 0 ? ( - - ) : ( - - )} -
- {log !== '' && } -
-
+ + + Please provide additional information related to your project. + + + You are onboarding {project?.name || '...'} project, + running workflow "{workflowName}" (execution ID: {executionId}) + + + + {allTasks.length > 0 ? ( + + ) : ( + + )} +
+ {log !== '' && } +
+
+
); -}; +} diff --git a/plugins/parodos/src/components/workflow/workflowDetail/WorkflowContext.tsx b/plugins/parodos/src/components/workflow/workflowDetail/WorkflowContext.tsx new file mode 100644 index 00000000..6edae919 --- /dev/null +++ b/plugins/parodos/src/components/workflow/workflowDetail/WorkflowContext.tsx @@ -0,0 +1,79 @@ +import { assert } from 'assert-ts'; +import React, { ReactNode, useContext } from 'react'; +import { createContext, useMemo } from 'react'; +import { useImmerReducer } from 'use-immer'; +import { WorkflowTask } from '../../../models/workflowTaskSchema'; + +type WorkFlowMode = 'RUNNING' | 'TASK_ALERT'; + +export interface WorkFlowContext { + workflowMode: WorkFlowMode; + showAlert(workflow: WorkflowTask): void; + clearAlert(): void; +} + +export type WorkflowActions = + | { + type: 'TASK_ALERT'; + } + | { + type: 'CLEAR_ALERT'; + }; + +interface State { + workflowMode: WorkFlowMode; +} + +const reducer = (draft: State, action: WorkflowActions) => { + switch (action.type) { + case 'TASK_ALERT': { + draft.workflowMode = 'TASK_ALERT'; + break; + } + case 'CLEAR_ALERT': { + draft.workflowMode = 'RUNNING'; + break; + } + default: + throw new Error(`no action`); + } +}; + +export const WorkflowContext = createContext( + undefined, +); + +const initialState: State = { + workflowMode: 'RUNNING', +}; + +export function WorkflowProvider({ + children, +}: { + children: ReactNode; +}): JSX.Element { + const [state, dispatch] = useImmerReducer(reducer, initialState); + + const value = useMemo( + () => ({ + workflowMode: state.workflowMode, + showAlert: () => dispatch({ type: 'TASK_ALERT' }), + clearAlert: () => dispatch({ type: 'CLEAR_ALERT' }), + }), + [dispatch, state.workflowMode], + ); + + return ( + + {children} + + ); +} + +export function useWorkflowContext() { + const context = useContext(WorkflowContext); + + assert(!!context, 'useWorkflowProvider must be within WorkflowProvider'); + + return context; +} diff --git a/plugins/parodos/src/components/workflow/workflowDetail/topology/DemoTaskNode.tsx b/plugins/parodos/src/components/workflow/workflowDetail/topology/DemoTaskNode.tsx index 2fba3405..def98552 100644 --- a/plugins/parodos/src/components/workflow/workflowDetail/topology/DemoTaskNode.tsx +++ b/plugins/parodos/src/components/workflow/workflowDetail/topology/DemoTaskNode.tsx @@ -1,10 +1,9 @@ -import * as React from 'react'; +import React, { useMemo } from 'react'; import { observer } from 'mobx-react'; import { DEFAULT_LAYER, DEFAULT_WHEN_OFFSET, Layer, - Node, ScaleDetailsLevel, TaskNode, TOP_LAYER, @@ -13,31 +12,40 @@ import { WhenDecorator, WithContextMenuProps, WithSelectionProps, + type Node, } from '@patternfly/react-topology'; +import { makeStyles } from '@material-ui/core'; +import pickBy from 'lodash.pickby'; +import cs from 'classnames'; +import { useWorkflowContext } from '../WorkflowContext'; type DemoTaskNodeProps = { element: Node; } & WithContextMenuProps & WithSelectionProps; +const useStyles = makeStyles(_theme => ({ + disabled: { + '& g': { + opacity: 0.6, + }, + }, +})); + const DemoTaskNode: any = ({ element, onContextMenu, contextMenuOpen, ...rest }: DemoTaskNodeProps) => { + const { workflowMode } = useWorkflowContext(); + const styles = useStyles(); const data = element.getData(); const [hover, hoverRef] = useHover(); const detailsLevel = useDetailsLevel(); - const passedData = React.useMemo(() => { - const newData = { ...data }; - Object.keys(newData).forEach(key => { - if (newData[key] === undefined) { - delete newData[key]; - } - }); - return newData; + const passedData: any = useMemo(() => { + return pickBy(data, n => typeof n !== 'undefined'); }, [data]); const hasTaskIcon = !!(data.taskIconClass || data.taskIcon); @@ -69,6 +77,10 @@ const DemoTaskNode: any = ({ {...passedData} {...rest} truncateLength={20} + className={cs({ + [styles.disabled]: + workflowMode === 'TASK_ALERT' && hasTaskIcon === false, + })} > {whenDecorator} diff --git a/plugins/parodos/src/components/workflow/workflowDetail/topology/PipelineLayout.tsx b/plugins/parodos/src/components/workflow/workflowDetail/topology/PipelineLayout.tsx index 291a376d..bfe7a479 100644 --- a/plugins/parodos/src/components/workflow/workflowDetail/topology/PipelineLayout.tsx +++ b/plugins/parodos/src/components/workflow/workflowDetail/topology/PipelineLayout.tsx @@ -24,6 +24,8 @@ import { useDemoPipelineNodes } from './useDemoPipelineNodes'; import { WorkflowTask } from '../../../../models/workflowTaskSchema'; import { useParentSize } from '@cutting/use-get-parent-size'; import { FirstTaskId } from '../../../../hooks/getWorkflowDefinitions'; +import { useWorkflowContext } from '../WorkflowContext'; +import { WorkflowAlert } from './WorkflowAlert'; export const PIPELINE_NODE_SEPARATION_VERTICAL = 10; @@ -35,11 +37,17 @@ type Props = { }; const TopologyPipelineLayout = ({ tasks, setSelectedTask }: Props) => { + const { workflowMode, showAlert, clearAlert } = useWorkflowContext(); const [selectedIds, setSelectedIds] = useState(); const pipelineNodes = useDemoPipelineNodes(tasks); const controller = useVisualizationController(); const containerRef = useRef(null); + const alertTask = useMemo( + () => tasks.find(t => !!t.alertMessage && t.status !== 'COMPLETED'), + [tasks], + ); + useParentSize(containerRef, { callback: () => { controller.getGraph().fit(70); @@ -47,6 +55,18 @@ const TopologyPipelineLayout = ({ tasks, setSelectedTask }: Props) => { debounceDelay: 500, }); + useEffect(() => { + if (workflowMode === 'RUNNING' && !!alertTask) { + showAlert(alertTask); + } + }, [alertTask, showAlert, workflowMode]); + + useEffect(() => { + if (workflowMode === 'TASK_ALERT' && !alertTask) { + clearAlert(); + } + }, [clearAlert, alertTask, workflowMode]); + useEffect(() => { const spacerNodes = getSpacerNodes(pipelineNodes); const nodes = [...pipelineNodes, ...spacerNodes]; @@ -76,6 +96,10 @@ const TopologyPipelineLayout = ({ tasks, setSelectedTask }: Props) => { }, [controller, pipelineNodes]); useEventListener(SELECTION_EVENT, ([taskId]) => { + if (!taskId) { + return; + } + const selected = tasks.find(task => task.id === taskId); if (!selected) { @@ -83,6 +107,7 @@ const TopologyPipelineLayout = ({ tasks, setSelectedTask }: Props) => { } if (taskId === FirstTaskId || selected.status === 'PENDING') { + setSelectedTask(taskId); return; } @@ -91,11 +116,14 @@ const TopologyPipelineLayout = ({ tasks, setSelectedTask }: Props) => { }); return ( -
- - - -
+ <> + {alertTask && } +
+ + + +
+ ); }; diff --git a/plugins/parodos/src/components/workflow/workflowDetail/topology/WorkFlowStepper.tsx b/plugins/parodos/src/components/workflow/workflowDetail/topology/WorkFlowStepper.tsx index 4373dce3..ca322f2c 100644 --- a/plugins/parodos/src/components/workflow/workflowDetail/topology/WorkFlowStepper.tsx +++ b/plugins/parodos/src/components/workflow/workflowDetail/topology/WorkFlowStepper.tsx @@ -4,6 +4,7 @@ import { PipelineLayout } from './PipelineLayout'; import { makeStyles } from '@material-ui/core'; import { WorkflowTask } from '../../../../models/workflowTaskSchema'; +import { WorkflowProvider } from '../WorkflowContext'; const useStyles = makeStyles(theme => ({ pfRi__topologyDemo: { @@ -23,7 +24,9 @@ export const WorkFlowStepper = (props: Props) => { const classes = useStyles(); return (
- + + +
); }; diff --git a/plugins/parodos/src/components/workflow/workflowDetail/topology/WorkflowAlert.tsx b/plugins/parodos/src/components/workflow/workflowDetail/topology/WorkflowAlert.tsx new file mode 100644 index 00000000..4f4abdb8 --- /dev/null +++ b/plugins/parodos/src/components/workflow/workflowDetail/topology/WorkflowAlert.tsx @@ -0,0 +1,116 @@ +import { Box, Fade, makeStyles, Paper } from '@material-ui/core'; +import assert from 'assert-ts'; +import React, { useLayoutEffect, useState } from 'react'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; +import { waitForElement } from '../../../../utils/wait'; +import { + DEFAULT_TASK_HEIGHT, + DEFAULT_TASK_WIDTH, +} from './useDemoPipelineNodes'; +import { WorkflowTask } from '../../../../models/workflowTaskSchema'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { renderers } from '../../../markdown/renderers'; + +const useStyles = makeStyles(theme => ({ + container: { + backgroundColor: 'transparent', + borderRadius: theme.spacing(2), + zIndex: 33, + }, + message: { + display: 'flex', + padding: '6px 16px', + fontSize: 'inherit', + fontFamily: 'inherit', + fontWeight: 400, + lineHeight: '1.43', + backgroundColor: theme.palette.warning.main, + color: theme.palette.common.white, + }, + icon: { + marginLeft: theme.spacing(2), + display: 'flex', + }, +})); + +interface WorkflowAlertProps { + task: WorkflowTask; +} + +export function WorkflowAlert({ + task, +}: WorkflowAlertProps): JSX.Element | null { + const styles = useStyles(); + const [open, setOpen] = useState(false); + const [{ top, left }, setDimensions] = useState({ + top: window.innerHeight / 2, + left: window.innerWidth / 2, + }); + + useLayoutEffect(() => { + async function positionAlert() { + let el: HTMLElement | null; + + try { + el = await waitForElement('.workflow-alert'); + + const domRect = el.getBoundingClientRect(); + + setDimensions({ + top: domRect.top + window.scrollY + DEFAULT_TASK_HEIGHT + 10, + left: domRect.left + window.scrollX + 5, + }); + } catch { + // unfortunately in smaller resolutions, patternfly/react-topology does + // not show the taskIcon so the .workflow-alert selector will fail + // In this case we center at the bottom of the svg + el = document.querySelector('.pf-topology-visualization-surface__svg'); + + if (!el) { + // this really should not happen but center on screen + setOpen(true); + return; + } + + const domRect = el.getBoundingClientRect(); + + setDimensions({ + top: domRect.bottom + window.scrollY, + left: domRect.left + window.scrollX + DEFAULT_TASK_WIDTH, + }); + } + + setOpen(true); + } + + setTimeout(positionAlert); + }, []); + + assert( + !!task.alertMessage, + `we are try to render an alert with no alertMessage`, + ); + + return ( + + + +
+
+ + {task.alertMessage} + +
+
+ +
+
+
+
+
+ ); +} diff --git a/plugins/parodos/src/components/workflow/workflowDetail/topology/useDemoPipelineNodes.tsx b/plugins/parodos/src/components/workflow/workflowDetail/topology/useDemoPipelineNodes.tsx index df49ded8..b26e98e0 100644 --- a/plugins/parodos/src/components/workflow/workflowDetail/topology/useDemoPipelineNodes.tsx +++ b/plugins/parodos/src/components/workflow/workflowDetail/topology/useDemoPipelineNodes.tsx @@ -8,27 +8,47 @@ import { import '@patternfly/react-styles/css/components/Topology/topology-components.css'; import LockIcon from '@material-ui/icons/Lock'; import { WorkflowTask } from '../../../../models/workflowTaskSchema'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; export const NODE_PADDING_VERTICAL = 15; -export const NODE_PADDING_HORIZONTAL = 10; +export const NODE_PADDING_HORIZONTAL = 5; -export const DEFAULT_TASK_WIDTH = 200; -export const DEFAULT_TASK_HEIGHT = 30; +export const DEFAULT_TASK_WIDTH = 150; +export const DEFAULT_TASK_HEIGHT = 32; + +function getTaskIcon(task: WorkflowTask): JSX.Element | null { + if (task.locked) { + return ; + } + + if (task.alertMessage && task.status !== 'COMPLETED') { + return ; + } + + return null; +} + +const WorkflowStatusRunStatusMap: Record = { + ['COMPLETED']: RunStatus.Succeeded, + ['IN_PROGRESS']: RunStatus.InProgress, + ['FAILED']: RunStatus.Failed, + ['REJECTED']: RunStatus.Failed, + ['PENDING']: RunStatus.Pending, +}; + +const RunStatusWhenStatusMap = { + [RunStatus.Succeeded]: WhenStatus.Met, + [RunStatus.InProgress]: WhenStatus.InProgress, +}; export function useDemoPipelineNodes( workflowTasks: WorkflowTask[], ): PipelineNodeModel[] { - const getStatus = (status: WorkflowTask['status']) => { - if (status === 'COMPLETED') return RunStatus.Succeeded; - else if (status === 'IN_PROGRESS') return RunStatus.InProgress; - else if (['FAILED', 'REJECTED'].includes(status)) return RunStatus.Failed; - return RunStatus.Pending; - }; - - const getConditionMet: any = (status: RunStatus) => { - if (status === RunStatus.Succeeded) return WhenStatus.Met; - else if (status === RunStatus.InProgress) return WhenStatus.InProgress; - return WhenStatus.Unmet; + const getConditionMet = (status: RunStatus) => { + return ( + RunStatusWhenStatusMap[status as keyof typeof RunStatusWhenStatusMap] ?? + WhenStatus.Unmet + ); }; const tasks = workflowTasks.map(workFlowTask => { @@ -45,17 +65,18 @@ export function useDemoPipelineNodes( }; task.data = { - status: getStatus(workFlowTask.status), - taskIcon: workFlowTask.locked ? : null, + status: WorkflowStatusRunStatusMap[workFlowTask.status], + taskIcon: getTaskIcon(workFlowTask), }; return task; }); const whenTasks = tasks.filter((_task, index) => index !== 0); - whenTasks.forEach(task => { + + for (const task of whenTasks) { task.data.whenStatus = getConditionMet(task.data.status); - }); + } return tasks; } diff --git a/plugins/parodos/src/models/workflowTaskSchema.ts b/plugins/parodos/src/models/workflowTaskSchema.ts index d9b51fa5..c73815c1 100644 --- a/plugins/parodos/src/models/workflowTaskSchema.ts +++ b/plugins/parodos/src/models/workflowTaskSchema.ts @@ -18,6 +18,7 @@ export const workflowTaskSchema = z.object({ status: transformedStatus, runAfterTasks: z.array(z.string()), locked: z.boolean(), + alertMessage: z.string().nullable().optional(), }); export const baseWorkStatusSchema = z.object({ @@ -25,6 +26,7 @@ export const baseWorkStatusSchema = z.object({ type: z.union([z.literal('TASK'), z.literal('WORKFLOW')]), status: transformedStatus, locked: z.boolean().optional().nullable(), + alertMessage: z.string().nullable().optional(), }); export type WorkStatus = z.infer & { diff --git a/plugins/parodos/src/utils/wait.ts b/plugins/parodos/src/utils/wait.ts new file mode 100644 index 00000000..9c1c9607 --- /dev/null +++ b/plugins/parodos/src/utils/wait.ts @@ -0,0 +1,25 @@ +export const waitForElement = ( + selector: string, + breakAt: number = 10, + delay = 100, +): Promise => { + return new Promise((resolve, reject) => { + const wait = (count = 0) => { + const el = document.querySelector(selector); + + if (!!el) { + resolve(el as E); + } else { + setTimeout(() => { + if (count < breakAt) { + wait(count + 1); + } else { + reject(new Error(`no element found for ${selector}`)); + } + }, delay); + } + }; + + wait(); + }); +}; diff --git a/yarn.lock b/yarn.lock index 1b116b47..c71848a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7350,6 +7350,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.pickby@^4.6.7": + version "4.6.7" + resolved "https://registry.yarnpkg.com/@types/lodash.pickby/-/lodash.pickby-4.6.7.tgz#fd089a5a7f8cbe7294ae5c90ea5ecd9f4cae4d2c" + integrity sha512-4ebXRusuLflfscbD0PUX4eVknDHD9Yf+uMtBIvA/hrnTqeAzbuHuDjvnYriLjUrI9YrhCPVKUf4wkRSXJQ6gig== + dependencies: + "@types/lodash" "*" + "@types/lodash.set@^4.3.7": version "4.3.7" resolved "https://registry.yarnpkg.com/@types/lodash.set/-/lodash.set-4.3.7.tgz#784fccea3fbef4d0949d1897a780f592da700942" @@ -15532,6 +15539,11 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== +lodash.pickby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" + integrity sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q== + lodash.set@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"