diff --git a/ui/.eslintrc b/ui/.eslintrc new file mode 100644 index 0000000000..9f8f2ab75e --- /dev/null +++ b/ui/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "react-app", + "rules": { + "import/no-anonymous-default-export": 0 + } +} diff --git a/ui/package.json b/ui/package.json index 947650415f..7022ac08db 100644 --- a/ui/package.json +++ b/ui/package.json @@ -46,12 +46,6 @@ "eject": "react-scripts eject", "prettier": "prettier --write ." }, - "eslintConfig": { - "extends": "react-app", - "rules": { - "import/no-anonymous-default-export": 0 - } - }, "browserslist": { "production": [ ">0.2%", @@ -86,6 +80,7 @@ "js-yaml": "3.13.1", "prettier": "^2.2.1", "sass": "^1.49.9", + "typescript": "^4.6.3", "webdriver": "^5.0.0", "webdriverio": "^5.0.0" }, diff --git a/ui/src/App.jsx b/ui/src/App.jsx index a724aacf7e..e89e696ff8 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -25,6 +25,7 @@ import Gantt from "./pages/kitchensink/Gantt"; import CustomRoutes from "./plugins/CustomRoutes"; import AppBarModules from "./plugins/AppBarModules"; import CustomAppBarButtons from "./plugins/CustomAppBarButtons"; +import Workbench from "./pages/workbench/Workbench"; const useStyles = makeStyles((theme) => ({ root: { @@ -68,6 +69,9 @@ export default function App() { +
@@ -107,6 +111,9 @@ export default function App() { + + + diff --git a/ui/src/components/Dropdown.jsx b/ui/src/components/Dropdown.jsx index f687c6d09e..8701042c11 100644 --- a/ui/src/components/Dropdown.jsx +++ b/ui/src/components/Dropdown.jsx @@ -10,6 +10,8 @@ export default function ({ style, error, helperText, + name, + value, ...props }) { return ( @@ -17,8 +19,14 @@ export default function ({ {label && {label}} ( - + )} + value={value === undefined ? null : value} // convert undefined to null {...props} /> diff --git a/ui/src/components/ReactJson.jsx b/ui/src/components/ReactJson.jsx index 92b72af803..686b84df2a 100644 --- a/ui/src/components/ReactJson.jsx +++ b/ui/src/components/ReactJson.jsx @@ -13,12 +13,12 @@ const useStyles = makeStyles({ height: "100%", display: "flex", flexDirection: "column", - paddingTop: 15 + paddingTop: 15, }, editorWrapper: { flex: 1, marginLeft: 10, - position: "relative" + position: "relative", }, label: { marginTop: 5, diff --git a/ui/src/components/StatusBadge.jsx b/ui/src/components/StatusBadge.jsx index c88bd83c6e..14b04c5a10 100644 --- a/ui/src/components/StatusBadge.jsx +++ b/ui/src/components/StatusBadge.jsx @@ -8,7 +8,7 @@ const colorMap = { warning: "#fba404", }; -export default function StatusBadge({ status }) { +export default function StatusBadge({ status, ...props }) { let color; switch (status) { case "RUNNING": @@ -26,6 +26,7 @@ export default function StatusBadge({ status }) { return ( diff --git a/ui/src/components/formik/FormikDropdown.jsx b/ui/src/components/formik/FormikDropdown.jsx new file mode 100644 index 0000000000..8e182f8658 --- /dev/null +++ b/ui/src/components/formik/FormikDropdown.jsx @@ -0,0 +1,19 @@ +import { useField } from "formik"; +import { Dropdown } from ".."; + +export default function (props) { + const [field, meta, helper] = useField(props); + const touchedError = meta.touched && meta.error; + + return ( + <> + helper.setValue(value)} + error={touchedError} + helperText={touchedError} + /> + + ); +} diff --git a/ui/src/components/formik/FormikJsonInput.jsx b/ui/src/components/formik/FormikJsonInput.jsx index 42b2caf269..41c3012241 100644 --- a/ui/src/components/formik/FormikJsonInput.jsx +++ b/ui/src/components/formik/FormikJsonInput.jsx @@ -1,12 +1,17 @@ -import React, { useRef } from "react"; +import { useRef, useEffect } from "react"; import { useField } from "formik"; import Editor from "@monaco-editor/react"; import { makeStyles } from "@material-ui/styles"; import { FormHelperText, InputLabel } from "@material-ui/core"; +import clsx from "clsx"; const useStyles = makeStyles({ + wrapper: { + width: "100%", + }, monaco: { padding: 10, + width: "100%", borderColor: "rgba(128, 128, 128, 0.2)", borderStyle: "solid", borderWidth: 1, @@ -25,7 +30,13 @@ const useStyles = makeStyles({ }, }); -export default function ({ className, label, height, ...props }) { +export default function ({ + className, + label, + height, + reinitialize = false, + ...props +}) { const classes = useStyles(); const [field, meta, helper] = useField(props); const editorRef = useRef(null); @@ -37,11 +48,18 @@ export default function ({ className, label, height, ...props }) { }); } + useEffect(() => { + if (reinitialize && editorRef.current) { + editorRef.current.getModel().setValue(field.value); + } + }, [reinitialize, field.value]); + return ( -
- +
+ {label} + + {meta.touched && meta.error ? ( {meta.error} diff --git a/ui/src/components/formik/FormikVersionDropdown.jsx b/ui/src/components/formik/FormikVersionDropdown.jsx new file mode 100644 index 0000000000..955312e81a --- /dev/null +++ b/ui/src/components/formik/FormikVersionDropdown.jsx @@ -0,0 +1,35 @@ +import { useFormikContext } from "formik"; +import { useWorkflowNamesAndVersions } from "../../data/workflow"; +import FormikDropdown from "./FormikDropdown"; +import { useEffect } from "react"; + +export default function FormikVersionDropdown(props) { + const { name } = props; + const { data: namesAndVersions } = useWorkflowNamesAndVersions(); + const { + setFieldValue, + values: { workflowName, workflowVersion }, + } = useFormikContext(); + + useEffect(() => { + if (workflowVersion && namesAndVersions.has(workflowName)) { + const found = namesAndVersions + .get(workflowName) + .find((row) => row.version.toString() === workflowVersion); + if (!found) { + console.log( + `Version ${workflowVersion} not found for new workflowName. Clearing dropdown.` + ); + setFieldValue(name, null, false); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [namesAndVersions, workflowName, workflowVersion]); + + const versions = + workflowName && namesAndVersions.has(workflowName) + ? namesAndVersions.get(workflowName).map((row) => "" + row.version) + : []; + + return ; +} diff --git a/ui/src/components/index.js b/ui/src/components/index.js index 4881f77d59..b7cae7f58c 100644 --- a/ui/src/components/index.js +++ b/ui/src/components/index.js @@ -28,3 +28,4 @@ export { default as ReactJson } from "./ReactJson"; // Misc export { default as LinearProgress } from "./LinearProgress"; export { default as Pill } from "./Pill"; +export { default as StatusBadge } from "./StatusBadge"; diff --git a/ui/src/data/common.js b/ui/src/data/common.js index b628e4c657..bf345a133e 100644 --- a/ui/src/data/common.js +++ b/ui/src/data/common.js @@ -1,20 +1,43 @@ import _ from "lodash"; -import { useQuery, useMutation } from "react-query"; +import { useQuery, useQueries, useMutation } from "react-query"; import { useFetchContext, fetchWithContext } from "../plugins/fetch"; +import assert from "assert"; + +export function useFetchParallel(paths, reactQueryOptions) { + const fetchContext = useFetchContext(); + + return useQueries( + paths.map((path) => { + assert(_.isArray(path)); + return { + queryKey: [fetchContext.stack, ...path], + queryFn: () => fetchWithContext(`/${path.join("/")}`, fetchContext), + enabled: + fetchContext.ready && _.get(reactQueryOptions, "enabled", true), + keepPreviousData: true, + ...reactQueryOptions, + }; + }) + ); +} export function useFetch(path, reactQueryOptions, defaultResponse) { const fetchContext = useFetchContext(); + const key = _.isArray(path) + ? [fetchContext.stack, ...path] + : [fetchContext.stack, path]; + const pathStr = _.isArray(path) ? `/${path.join("/")}` : path; return useQuery( - [fetchContext.stack, path], + key, () => { - if (path) { - return fetchWithContext(path, fetchContext); + if (pathStr) { + return fetchWithContext(pathStr, fetchContext); } else { return Promise.resolve(defaultResponse); } }, { - enabled: fetchContext.ready, + enabled: fetchContext.ready && _.get(reactQueryOptions, "enabled", true), keepPreviousData: true, ...reactQueryOptions, } diff --git a/ui/src/data/workflow.js b/ui/src/data/workflow.js index 3aeffe2f3d..99a77eafb1 100644 --- a/ui/src/data/workflow.js +++ b/ui/src/data/workflow.js @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import { useQuery, useMutation } from "react-query"; -import qs from "qs"; +import { useQuery, useMutation, useQueryClient } from "react-query"; import { useFetchContext, fetchWithContext } from "../plugins/fetch"; -import { useFetch } from "./common"; +import { useFetch, useFetchParallel } from "./common"; +import { useEnv } from "../plugins/env"; +import qs from "qs"; const STALE_TIME_WORKFLOW_DEFS = 600000; // 10 mins const STALE_TIME_SEARCH = 60000; // 1 min @@ -36,16 +37,43 @@ export function useWorkflowSearch(searchObj) { } export function useWorkflow(workflowId) { - return useFetch(`/workflow/${workflowId}`); + return useFetch(`/workflow/${workflowId}`, { enabled: !!workflowId }); +} + +export function useWorkflows(workflowIds, reactQueryOptions) { + return useFetchParallel( + workflowIds.map((workflowId) => ["workflow", workflowId]), + reactQueryOptions + ); +} + +export function useInvalidateWorkflows() { + const { stack } = useEnv(); + const client = useQueryClient(); + + return function (workflowIds) { + console.log("invalidating workflow Ids", workflowIds); + client.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === stack && + query.queryKey[1] === "workflow" && + workflowIds.includes(query.queryKey[2]), + }); + }; } -export function useWorkflowDef(workflowName, version, defaultWorkflow) { +export function useWorkflowDef( + workflowName, + version, + defaultWorkflow, + reactQueryOptions = {} +) { let path; if (workflowName) { path = `/metadata/workflow/${workflowName}`; if (version) path += `?version=${version}`; } - return useFetch(path, {}, defaultWorkflow); + return useFetch(path, reactQueryOptions, defaultWorkflow); } export function useWorkflowDefs() { @@ -135,3 +163,25 @@ export function useWorkflowNamesAndVersions() { return { ...rest, data: newData }; } + +export function useStartWorkflow(callbacks) { + const path = "/workflow"; + const fetchContext = useFetchContext(); + + return useMutation( + ({ body }) => + fetchWithContext( + path, + fetchContext, + { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + false + ), + callbacks + ); +} diff --git a/ui/src/pages/definition/EventHandler.jsx b/ui/src/pages/definition/EventHandler.jsx index 16aaad512f..a4457bdfd7 100644 --- a/ui/src/pages/definition/EventHandler.jsx +++ b/ui/src/pages/definition/EventHandler.jsx @@ -17,8 +17,8 @@ const useStyles = makeStyles({ paper: { flex: 1, margin: 30, - paddingTop: 10 - } + paddingTop: 10, + }, }); export default function EventHandlerDefinition() { diff --git a/ui/src/pages/execution/ActionModule.jsx b/ui/src/pages/execution/ActionModule.jsx index 8de1ee27b0..853e97ee72 100644 --- a/ui/src/pages/execution/ActionModule.jsx +++ b/ui/src/pages/execution/ActionModule.jsx @@ -51,7 +51,7 @@ export default function ActionModule({ execution, triggerReload }) { options.push({ label: ( <> - + Restart with Current Definitions ), @@ -61,7 +61,7 @@ export default function ActionModule({ execution, triggerReload }) { options.push({ label: ( <> - + Restart with Latest Definitions ), @@ -78,7 +78,6 @@ export default function ActionModule({ execution, triggerReload }) { label: ( <> @@ -90,7 +89,7 @@ export default function ActionModule({ execution, triggerReload }) { { label: ( <> - + Pause ), @@ -104,7 +103,7 @@ export default function ActionModule({ execution, triggerReload }) { } else if (execution.status === "PAUSED") { return ( resumeAction.mutate()}> - + Resume ); @@ -115,7 +114,7 @@ export default function ActionModule({ execution, triggerReload }) { options.push({ label: ( <> - + Restart with Current Definitions ), @@ -125,7 +124,7 @@ export default function ActionModule({ execution, triggerReload }) { options.push({ label: ( <> - + Restart with Latest Definitions ), @@ -136,7 +135,7 @@ export default function ActionModule({ execution, triggerReload }) { options.push({ label: ( <> - + Retry - From failed task ), @@ -153,7 +152,7 @@ export default function ActionModule({ execution, triggerReload }) { options.push({ label: ( <> - + Retry - Resume subworkflow ), diff --git a/ui/src/pages/execution/Execution.jsx b/ui/src/pages/execution/Execution.jsx index b172494997..7483833919 100644 --- a/ui/src/pages/execution/Execution.jsx +++ b/ui/src/pages/execution/Execution.jsx @@ -75,7 +75,7 @@ const useStyles = makeStyles({ backgroundColor: "#fff", display: "flex", flexDirection: "column", - overflow: "hidden" + overflow: "hidden", }, content: { height: "100%", @@ -89,7 +89,7 @@ const useStyles = makeStyles({ flex: 1, overflow: "hidden", display: "flex", - flexDirection: "column" + flexDirection: "column", }, headerSubtitle: { marginBottom: 20, @@ -135,7 +135,7 @@ export default function Execution() { [execution] ); const selectedTask = useMemo( - () => dag && selectedTaskJson ? rison.decode(selectedTaskJson) : null, + () => (dag && selectedTaskJson ? rison.decode(selectedTaskJson) : null), [dag, selectedTaskJson] ); @@ -195,113 +195,115 @@ export default function Execution() { }; }, [handleMousemove]); - return <> - - Conductor UI - Execution - {match.params.id} - -
- {isFetching && } - {execution && ( - <> -
-
- {execution.parentWorkflowId && ( -
- - Parent Workflow - -
- )} - - Refresh - - -
- - {execution.workflowType || execution.workflowName}{" "} - - - - {execution.workflowId} - + return ( + <> + + Conductor UI - Execution - {match.params.id} + +
+ {isFetching && } + {execution && ( + <> +
+
+ {execution.parentWorkflowId && ( +
+ + Parent Workflow + +
+ )} + + Refresh + + +
+ + {execution.workflowType || execution.workflowName}{" "} + + + + {execution.workflowId} + - {execution.reasonForIncompletion && ( - - {execution.reasonForIncompletion} - - )} + {execution.reasonForIncompletion && ( + + {execution.reasonForIncompletion} + + )} - - setTabIndex(0)} /> - setTabIndex(1)} /> - setTabIndex(2)} - /> - setTabIndex(3)} /> - -
-
- {tabIndex === 0 && ( - - )} - {tabIndex === 1 && } - {tabIndex === 2 && } - {tabIndex === 3 && } -
- - )} -
- {selectedTask && ( -
-
handleMousedown(event)} - className={classes.dragger} - /> -
-
- {isFullWidth ? ( - - handleFullScreenExit()}> - - - - ) : ( - - handleFullScreen()}> - + + setTabIndex(0)} /> + setTabIndex(1)} /> + setTabIndex(2)} + /> + setTabIndex(3)} /> + +
+
+ {tabIndex === 0 && ( + + )} + {tabIndex === 1 && } + {tabIndex === 2 && } + {tabIndex === 3 && } +
+ + )} +
+ {selectedTask && ( +
+
handleMousedown(event)} + className={classes.dragger} + /> +
+
+ {isFullWidth ? ( + + handleFullScreenExit()}> + + + + ) : ( + + handleFullScreen()}> + + + + )} + + handleClose()}> + - )} - - handleClose()}> - - - -
-
- +
+
+ +
-
- )} - + )} + + ); } diff --git a/ui/src/pages/execution/ExecutionInputOutput.jsx b/ui/src/pages/execution/ExecutionInputOutput.jsx index 6663b8a2e5..65a6202dce 100644 --- a/ui/src/pages/execution/ExecutionInputOutput.jsx +++ b/ui/src/pages/execution/ExecutionInputOutput.jsx @@ -16,11 +16,11 @@ const useStyles = makeStyles({ gap: 15, flex: 2, marginBottom: 15, - overflow: "hidden" + overflow: "hidden", }, paper: { flex: 1, - overflow: "hidden" + overflow: "hidden", }, }); diff --git a/ui/src/pages/execution/ExecutionJson.jsx b/ui/src/pages/execution/ExecutionJson.jsx index 34d8ed6ca4..85eb0f4905 100644 --- a/ui/src/pages/execution/ExecutionJson.jsx +++ b/ui/src/pages/execution/ExecutionJson.jsx @@ -4,15 +4,15 @@ import ReactJson from "../../components/ReactJson"; import { makeStyles } from "@material-ui/styles"; const useStyles = makeStyles({ - paper: { + paper: { margin: 30, - flex: 1 + flex: 1, }, wrapper: { flex: 1, display: "flex", - flexDirection: "column" - } + flexDirection: "column", + }, }); export default function ExecutionJson({ execution }) { @@ -20,9 +20,9 @@ export default function ExecutionJson({ execution }) { return (
- - - + + +
); } diff --git a/ui/src/pages/execution/ExecutionSummary.jsx b/ui/src/pages/execution/ExecutionSummary.jsx index b04c506d47..37ebeacd50 100644 --- a/ui/src/pages/execution/ExecutionSummary.jsx +++ b/ui/src/pages/execution/ExecutionSummary.jsx @@ -4,11 +4,11 @@ import { makeStyles } from "@material-ui/styles"; const useStyles = makeStyles({ paper: { - margin: 30 + margin: 30, }, wrapper: { - overflowY: "auto" - } + overflowY: "auto", + }, }); export default function ExecutionSummary({ execution }) { @@ -55,9 +55,9 @@ export default function ExecutionSummary({ execution }) { return (
- - - + + +
); } diff --git a/ui/src/pages/execution/RightPanel.jsx b/ui/src/pages/execution/RightPanel.jsx index 32298a92dd..4a7f6a8e07 100644 --- a/ui/src/pages/execution/RightPanel.jsx +++ b/ui/src/pages/execution/RightPanel.jsx @@ -17,7 +17,7 @@ const useStyles = makeStyles({ }, tabContent: { flex: 1, - overflowY: 'auto' + overflowY: "auto", }, }); @@ -115,10 +115,7 @@ export default function RightPanel({ selectedTask, dag, onTaskChange }) {
{tabIndex === 0 && } {tabIndex === 1 && ( - + )} {tabIndex === 2 && ( <> @@ -129,10 +126,7 @@ export default function RightPanel({ selectedTask, dag, onTaskChange }) { location. )} - + )} {tabIndex === 3 && } diff --git a/ui/src/pages/execution/TaskDetails.jsx b/ui/src/pages/execution/TaskDetails.jsx index d3fbee4fdf..379dd23f70 100644 --- a/ui/src/pages/execution/TaskDetails.jsx +++ b/ui/src/pages/execution/TaskDetails.jsx @@ -7,10 +7,10 @@ import { makeStyles } from "@material-ui/styles"; const useStyles = makeStyles({ taskWrapper: { - overflowY: 'auto', + overflowY: "auto", padding: 30, - height: '100%' - } + height: "100%", + }, }); export default function TaskDetails({ @@ -24,38 +24,38 @@ export default function TaskDetails({ return (
- - - setTabIndex(0)} /> - setTabIndex(1)} /> - setTabIndex(2)} /> - + + + setTabIndex(0)} /> + setTabIndex(1)} /> + setTabIndex(2)} /> + - {tabIndex === 0 && ( - - )} - {tabIndex === 1 && ( - - )} - {tabIndex === 2 && ( - - )} - + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + {tabIndex === 2 && ( + + )} +
); } diff --git a/ui/src/pages/workbench/ExecutionHistory.jsx b/ui/src/pages/workbench/ExecutionHistory.jsx new file mode 100644 index 0000000000..90a8acaf8c --- /dev/null +++ b/ui/src/pages/workbench/ExecutionHistory.jsx @@ -0,0 +1,91 @@ +import { + List, + ListItem, + ListItemText, + Toolbar, + IconButton, +} from "@material-ui/core"; +import { StatusBadge, Text, NavLink } from "../../components"; +import { makeStyles } from "@material-ui/styles"; +import { colors } from "../../theme/variables"; +import _ from "lodash"; +import { useInvalidateWorkflows, useWorkflows } from "../../data/workflow"; +import { formatRelative } from "date-fns"; +import RefreshIcon from "@material-ui/icons/Refresh"; + +const useStyles = makeStyles({ + sidebar: { + width: 360, + border: "0px solid rgba(0, 0, 0, 0)", + zIndex: 1, + boxShadow: "0 2px 4px 0 rgb(0 0 0 / 10%), 0 0 2px 0 rgb(0 0 0 / 10%)", + background: "#fff", + display: "flex", + flexDirection: "column", + }, + toolbar: { + backgroundColor: colors.gray14, + }, + list: { + overflowY: "auto", + flex: 1, + }, +}); + +export default function ExecutionHistory({ run }) { + const classes = useStyles(); + const workflowRecords = run ? run.workflowRecords : []; + const workflowIds = workflowRecords.map((record) => `${record.workflowId}`); + const results = + useWorkflows(workflowIds, { + staleTime: 60000, + }) || []; + const resultsMap = new Map( + results + .filter((r) => r.isSuccess) + .map((result) => [result.data.workflowId, result.data]) + ); + const invalidateWorkflows = useInvalidateWorkflows(); + + function handleRefresh() { + invalidateWorkflows(workflowIds); + } + + return ( +
+ + + Execution History + + + + + + + {Array.from(resultsMap.values()).map((workflow) => ( + + + {workflow.workflowId} + + } + secondary={ + + {" "} + {formatRelative(new Date(workflow.startTime), new Date())} + + } + secondaryTypographyProps={{ component: "div" }} + /> + + ))} + {_.isEmpty(workflowRecords) && ( + + No execution history. + + )} + +
+ ); +} diff --git a/ui/src/pages/workbench/RunHistory.tsx b/ui/src/pages/workbench/RunHistory.tsx new file mode 100644 index 0000000000..9ae149604e --- /dev/null +++ b/ui/src/pages/workbench/RunHistory.tsx @@ -0,0 +1,167 @@ +import { useImperativeHandle, useState, forwardRef } from "react"; +import { useLocalStorage } from "../../utils/localstorage"; +import { Text } from "../../components"; +import { + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Toolbar, + IconButton, +} from "@material-ui/core"; +import { makeStyles } from "@material-ui/styles"; +import { immutableReplaceAt } from "../../utils/helpers"; +import { formatRelative } from "date-fns"; +import DeleteIcon from "@material-ui/icons/DeleteForever"; +import { colors } from "../../theme/variables"; +import CloseIcon from "@material-ui/icons/Close"; +import _ from "lodash"; +import { useEnv } from "../../plugins/env"; + +const useStyles = makeStyles({ + sidebar: { + width: 300, + border: "0px solid rgba(0, 0, 0, 0)", + zIndex: 1, + boxShadow: "0 2px 4px 0 rgb(0 0 0 / 10%), 0 0 2px 0 rgb(0 0 0 / 10%)", + background: "#fff", + display: "flex", + flexDirection: "column", + }, + toolbar: { + backgroundColor: colors.gray14, + }, + title: { + fontWeight: "bold", + flex: 1, + }, + list: { + overflowY: "auto", + cursor: "pointer", + flex: 1, + }, +}); +type RunPayload = any; +type RunEntry = { + runPayload: RunPayload; + workflowRecords: WorkflowRecord[]; + createTime: number; +}; +type WorkflowRecord = { + workflowId: string; +}; + +type RunHistoryProps = { + onRunSelected: (run: RunEntry | undefined) => void; +}; + +const RUN_HISTORY_SCHEMA_VER = 1; + +const RunHistory = forwardRef((props: RunHistoryProps, ref) => { + const { onRunSelected } = props; + const { stack } = useEnv(); + const classes = useStyles(); + const [selectedCreateTime, setSelectedCreateTime] = useState< + number | undefined + >(undefined); + const [runHistory, setRunHistory]: readonly [ + RunEntry[], + (v: RunEntry[]) => void + ] = useLocalStorage(`runHistory_${stack}_${RUN_HISTORY_SCHEMA_VER}`, []); + + useImperativeHandle(ref, () => ({ + pushNewRun: (runPayload: RunPayload) => { + const createTime = new Date().getTime(); + const newRun = { + runPayload: runPayload, + workflowRecords: [], + createTime: createTime, + }; + setRunHistory([newRun, ...runHistory]); + setSelectedCreateTime(createTime); + + return newRun; + }, + updateRun: (createTime: number, workflowId: string) => { + console.log("updating run", createTime, workflowId); + + const idx = runHistory.findIndex((v) => v.createTime === createTime); + const currRun = runHistory[idx]; + const oldRecords = currRun.workflowRecords; + const updatedRun = { + runPayload: currRun.runPayload, + workflowRecords: [ + { + workflowId: workflowId, + }, + ...oldRecords, + ], + createTime: currRun.createTime, + }; + + setRunHistory(immutableReplaceAt(runHistory, idx, updatedRun)); + onRunSelected(updatedRun); + }, + })); + + function handleSelectRun(run: RunEntry) { + if (onRunSelected) onRunSelected(run); + setSelectedCreateTime(run.createTime); + } + + function handleDeleteAll() { + if (window.confirm("Delete all run history in this browser?")) { + setRunHistory([]); + } + } + + function handleDeleteItem(run: RunEntry) { + const newHistory = runHistory.filter( + (v) => v.createTime !== run.createTime + ); + if (newHistory.length > 0) { + setSelectedCreateTime(newHistory[0].createTime); + onRunSelected(newHistory[0]); + } else { + console.log("Empty history"); + setSelectedCreateTime(undefined); + onRunSelected(undefined); + } + setRunHistory(newHistory); + } + + return ( +
+ + + Run History + + + + + + + {runHistory.map((run) => ( + handleSelectRun(run)} + > + + + handleDeleteItem(run)}> + + + + + ))} + {_.isEmpty(runHistory) && No saved runs.} + +
+ ); +}); + +export default RunHistory; diff --git a/ui/src/pages/workbench/Workbench.jsx b/ui/src/pages/workbench/Workbench.jsx new file mode 100644 index 0000000000..17e58d0dc0 --- /dev/null +++ b/ui/src/pages/workbench/Workbench.jsx @@ -0,0 +1,98 @@ +import { useState, useRef } from "react"; +import { makeStyles } from "@material-ui/styles"; +import { Helmet } from "react-helmet"; +import RunHistory from "./RunHistory"; +import WorkbenchForm from "./WorkbenchForm"; +import { colors } from "../../theme/variables"; +import { useStartWorkflow } from "../../data/workflow"; +import ExecutionHistory from "./ExecutionHistory"; + +const useStyles = makeStyles({ + wrapper: { + height: "100%", + overflow: "hidden", + display: "flex", + flexDirection: "row", + position: "relative", + }, + name: { + width: "50%", + }, + submitButton: { + float: "right", + }, + toolbar: { + backgroundColor: colors.gray14, + }, + workflowName: { + fontWeight: "bold", + }, + main: { + flex: 1, + display: "flex", + flexDirection: "column", + }, + row: { + display: "flex", + flexDirection: "row", + }, + fields: { + margin: 30, + flex: 1, + display: "flex", + flexDirection: "column", + gap: 15, + }, + runInfo: { + marginLeft: -350, + }, +}); + +export default function Workbench() { + const classes = useStyles(); + + const runHistoryRef = useRef(); + const [run, setRun] = useState(undefined); + + const { mutate: startWorkflow } = useStartWorkflow({ + onSuccess: (workflowId, variables) => { + runHistoryRef.current.updateRun(variables.createTime, workflowId); + }, + }); + + const handleRunSelect = (run) => { + setRun(run); + }; + + const handleSaveRun = (runPayload) => { + const newRun = runHistoryRef.current.pushNewRun(runPayload); + setRun(newRun); + return newRun; + }; + + const handleExecuteRun = (createTime, runPayload) => { + startWorkflow({ + createTime, + body: runPayload, + }); + }; + + return ( + <> + + Conductor UI - Workbench + + +
+ + + + +
+ + ); +} diff --git a/ui/src/pages/workbench/WorkbenchForm.jsx b/ui/src/pages/workbench/WorkbenchForm.jsx new file mode 100644 index 0000000000..c723b4338f --- /dev/null +++ b/ui/src/pages/workbench/WorkbenchForm.jsx @@ -0,0 +1,245 @@ +import { useMemo } from "react"; +import { Text, Pill } from "../../components"; +import { Toolbar, IconButton } from "@material-ui/core"; +import FormikInput from "../../components/formik/FormikInput"; +import FormikJsonInput from "../../components/formik/FormikJsonInput"; +import FormikDropdown from "../../components/formik/FormikDropdown"; +import { makeStyles } from "@material-ui/styles"; +import _ from "lodash"; +import { Form, setNestedObjectValues, withFormik } from "formik"; +import { + useWorkflowNamesAndVersions, + useWorkflowDef, +} from "../../data/workflow"; +import FormikVersionDropdown from "../../components/formik/FormikVersionDropdown"; +import PlayArrowIcon from "@material-ui/icons/PlayArrow"; +import PlaylistAddIcon from "@material-ui/icons/PlaylistAdd"; +import SaveIcon from "@material-ui/icons/Save"; +import { colors } from "../../theme/variables"; +import { timestampRenderer } from "../../utils/helpers"; +import * as Yup from "yup"; + +const useStyles = makeStyles({ + name: { + width: "50%", + }, + submitButton: { + float: "right", + }, + toolbar: { + backgroundColor: colors.gray14, + }, + workflowName: { + fontWeight: "bold", + }, + main: { + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "auto", + }, + fields: { + width: "100%", + padding: 30, + flex: 1, + display: "flex", + flexDirection: "column", + overflowX: "hidden", + overflowY: "auto", + gap: 15, + }, +}); + +Yup.addMethod(Yup.string, "isJson", function () { + return this.test("is-json", "is not valid json", (value) => { + if (_.isEmpty(value)) return true; + + try { + JSON.parse(value); + } catch (e) { + return false; + } + return true; + }); +}); +const validationSchema = Yup.object({ + workflowName: Yup.string().required("Workflow Name is required"), + workflowInput: Yup.string().isJson(), + taskToDomain: Yup.string().isJson(), +}); + +export default withFormik({ + enableReinitialize: true, + mapPropsToValues: ({ selectedRun }) => + runPayloadToFormData(_.get(selectedRun, "runPayload")), + validationSchema: validationSchema, +})(WorkbenchForm); + +function WorkbenchForm(props) { + const { + values, + validateForm, + setTouched, + setFieldValue, + dirty, + selectedRun, + saveRun, + executeRun, + } = props; + const classes = useStyles(); + const { workflowName, workflowVersion } = values; + const createTime = selectedRun ? selectedRun.createTime : undefined; + + const { data: namesAndVersions } = useWorkflowNamesAndVersions(); + const workflowNames = useMemo( + () => (namesAndVersions ? Array.from(namesAndVersions.keys()) : []), + [namesAndVersions] + ); + const { refetch } = useWorkflowDef(workflowName, workflowVersion, null, { + onSuccess: populateInput, + enabled: false, + }); + + function triggerPopulateInput() { + refetch(); + } + + function populateInput(workflowDef) { + let bootstrap = {}; + + if (!_.isEmpty(values.workflowInput)) { + const existing = JSON.parse(values.workflowInput); + bootstrap = _.pickBy(existing, (v) => v !== ""); + } + + if (workflowDef.inputParameters) { + for (let param of workflowDef.inputParameters) { + if (!_.has(bootstrap, param)) { + bootstrap[param] = ""; + } + } + + setFieldValue("workflowInput", JSON.stringify(bootstrap, null, 2)); + } + } + + function handleRun() { + validateForm().then((errors) => { + if (Object.keys(errors).length === 0) { + const payload = formDataToRunPayload(values); + if (!dirty && createTime) { + console.log("Executing pre-existing run. Append workflowRecord"); + executeRun(createTime, payload); + } else { + console.log("Executing new run. Save first then execute"); + const newRun = saveRun(payload); + executeRun(newRun.createTime, payload); + } + } else { + // Handle validation error manually (not using handleSubmit) + setTouched(setNestedObjectValues(errors, true)); + } + }); + } + + function handleSave() { + validateForm().then((errors) => { + if (Object.keys(errors).length === 0) { + const payload = formDataToRunPayload(values); + console.log(payload); + saveRun(payload); + } else { + setTouched(setNestedObjectValues(errors, true)); + } + }); + } + + return ( +
+ + Workflow Workbench + + + + + + + + + + + {dirty && } + {createTime && Created: {timestampRenderer(createTime)}} + + +
+ + + + + + + + + +
+
+ ); +} + +function runPayloadToFormData(runPayload) { + return { + workflowName: _.get(runPayload, "name", ""), + workflowVersion: _.get(runPayload, "version", ""), + workflowInput: _.has(runPayload, "input") + ? JSON.stringify(runPayload.input, null, 2) + : "", + correlationId: _.get(runPayload, "correlationId", ""), + taskToDomain: _.has(runPayload, "taskToDomain") + ? JSON.stringify(runPayload.taskToDomain, null, 2) + : "", + }; +} + +function formDataToRunPayload(form) { + let runPayload = { + name: form.workflowName, + }; + if (form.workflowVersion) { + runPayload.version = form.workflowVersion; + } + if (form.workflowInput) { + runPayload.input = JSON.parse(form.workflowInput); + } + if (form.correlationId) { + runPayload.correlationId = form.correlationId; + } + if (form.taskToDomain) { + runPayload.taskToDomain = JSON.parse(form.taskToDomain); + } + return runPayload; +} + +// runHistoryRef.current.pushRun(runPayload); diff --git a/ui/src/plugins/fetch.js b/ui/src/plugins/fetch.js index 0ac96fe1a2..2a15b41c2d 100644 --- a/ui/src/plugins/fetch.js +++ b/ui/src/plugins/fetch.js @@ -7,7 +7,7 @@ export function useFetchContext() { ready: true, }; } -export function fetchWithContext(path, context, fetchParams) { +export function fetchWithContext(path, context, fetchParams, isJsonResponse) { const newParams = { ...fetchParams }; const newPath = `/api/${path}`; @@ -22,6 +22,8 @@ export function fetchWithContext(path, context, fetchParams) { return Promise.reject(error); } else if (!text || text.length === 0) { return null; + } else if (!isJsonResponse) { + return text; } else { try { return JSON.parse(text); diff --git a/ui/src/react-app-env.d.ts b/ui/src/react-app-env.d.ts new file mode 100644 index 0000000000..6431bc5fc6 --- /dev/null +++ b/ui/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/src/theme/theme.js b/ui/src/theme/theme.js index 697c34169c..b86795b1da 100644 --- a/ui/src/theme/theme.js +++ b/ui/src/theme/theme.js @@ -196,6 +196,9 @@ const overrides = { root: { fontSize: fontSizes.fontSize6, }, + fontSizeSmall: { + fontSize: fontSizes.fontSize1, + }, }, MuiAvatar: { root: { @@ -297,6 +300,10 @@ const overrides = { paddingLeft: baseTheme.spacing("space1"), paddingRight: baseTheme.spacing("space1"), }, + sizeSmall: { + fontSize: fontSizes.fontSize0, + height: 20, + }, deleteIcon: { height: "100%", padding: 3, @@ -506,8 +513,8 @@ const overrides = { height: 4, }, root: { - minHeight: 0 - } + minHeight: 0, + }, }, MuiListItemText: { secondary: { @@ -584,7 +591,7 @@ const overrides = { color: colors.gray00, }, root: { - zIndex: 0, + zIndex: 999, paddingLeft: 20, paddingRight: 20, boxShadow: "0 4px 8px 0 rgb(0 0 0 / 10%), 0 0 2px 0 rgb(0 0 0 / 10%)", diff --git a/ui/src/utils/helpers.js b/ui/src/utils/helpers.js index a1ef51f4c4..5682a52de4 100644 --- a/ui/src/utils/helpers.js +++ b/ui/src/utils/helpers.js @@ -66,3 +66,9 @@ export function defaultCompare(x, y) { return 0; } + +export function immutableReplaceAt(array, index, value) { + const ret = array.slice(0); + ret[index] = value; + return ret; +} diff --git a/ui/src/utils/localstorage.ts b/ui/src/utils/localstorage.ts new file mode 100644 index 0000000000..3d7e785599 --- /dev/null +++ b/ui/src/utils/localstorage.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; + +// If key is null/undefined, hook behaves exactly like useState +export const useLocalStorage = (key: string, initialValue: any) => { + const initialString = JSON.stringify(initialValue); + + const [storedValue, setStoredValue] = useState(() => { + if (key) { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } else { + return initialValue; + } + }); + + const setValue = (value: any) => { + // Allow value to be a function so we have same API as useState + const valueToStore = value instanceof Function ? value(storedValue) : value; + + // Save state + setStoredValue(valueToStore); + + if (key) { + const stringToStore = JSON.stringify(valueToStore); + if (stringToStore === initialString) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, stringToStore); + } + } + }; + + return [storedValue, setValue] as const; +}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000000..9d379a3c4a --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/ui/yarn.lock b/ui/yarn.lock index 3d9cefa80e..5ba8a393af 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -13330,6 +13330,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typescript@^4.6.3: + version "4.6.3" + resolved "https://artifacts.netflix.net/api/npm/npm-netflix/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" + integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw== + unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"