diff --git a/.eslintignore b/.eslintignore index fffa32fdf..dee25c399 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ dist coverage **/*.d.ts tests +lib **/__tests__ ui-tests diff --git a/package.json b/package.json index ed08a65ea..1f1a44e27 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "@emotion/styled": "^11.10.4", "@jupyterlab/application": "^4", "@jupyterlab/apputils": "^4", + "@jupyterlab/codeeditor": "^4", + "@jupyterlab/codemirror": "^4", "@jupyterlab/coreutils": "^6", "@jupyterlab/filebrowser": "^4", "@jupyterlab/launcher": "^4", @@ -72,11 +74,23 @@ "@mui/icons-material": "^5.10.9", "@mui/material": "^5.10.6", "@mui/system": "^5.10.6", + "@mui/x-date-pickers": "^6.19.3", "@types/react-dom": "^18.0.5", "cronstrue": "^2.12.0", + "elkjs": "^0.9.1", + "email-validator": "^2.0.4", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "nanoid": "^3.3.7", "react": "^18.2.0", "react-dom": "^18.2.0", - "tzdata": "^1.0.33" + "react-hook-form": "^7.50.1", + "react-resizable-panels": "^2.0.9", + "react-router-dom": "^6.22.0", + "react-show-more-text": "^1.7.1", + "reactflow": "^11.10.3", + "tzdata": "^1.0.33", + "zustand": "^4.5.0" }, "devDependencies": { "@babel/core": "^7.0.0", @@ -84,9 +98,11 @@ "@jupyterlab/builder": "^4", "@jupyterlab/testutils": "^4", "@types/jest": "^29", - "@typescript-eslint/eslint-plugin": "^4.8.1", - "@typescript-eslint/parser": "^4.8.1", - "eslint": "^7.14.0", + "@types/lodash": "4.14.192", + "@types/react-show-more-text": "^1.4.5", + "@typescript-eslint/eslint-plugin": "^7.12.0", + "@typescript-eslint/parser": "^7.12.0", + "eslint": "^8.57.0", "eslint-config-prettier": "^6.15.0", "eslint-plugin-prettier": "^3.1.4", "jest": "^29", @@ -100,7 +116,8 @@ "stylelint-config-standard": "~24.0.0", "stylelint-prettier": "^2.0.0", "ts-jest": "^29", - "typescript": "~4.3.0" + "typescript": "~5.1.6", + "zustand-types": "^0.1.0" }, "resolutions": { "@types/react": "^17.0.1", diff --git a/schema/plugin.json b/schema/plugin.json index 381516faa..06243987a 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -8,6 +8,11 @@ "command": "scheduling:create-from-filebrowser", "selector": ".jp-DirListing-item[data-file-type=\"notebook\"]", "rank": 3.9 + }, + { + "command": "workflows:create-from-filebrowser", + "selector": ".jp-DirListing-item[data-file-type=\"notebook\"]", + "rank": 4 } ] }, @@ -17,6 +22,11 @@ "name": "create-job", "command": "scheduling:create-from-notebook", "rank": 46 + }, + { + "name": "create-workflow", + "command": "workflows:create-from-notebook", + "rank": 47 } ] }, diff --git a/src/dag-scheduler/components/advanced-table/advanced-table-header.tsx b/src/dag-scheduler/components/advanced-table/advanced-table-header.tsx new file mode 100644 index 000000000..27a30e879 --- /dev/null +++ b/src/dag-scheduler/components/advanced-table/advanced-table-header.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { caretDownIcon, caretUpIcon, LabIcon } from '@jupyterlab/ui-components'; +import TableHead from '@mui/material/TableHead'; +import TableCell from '@mui/material/TableCell'; + +import { AdvancedTableColumn, AdvancedTableQuery } from './advanced-table'; +import { Scheduler } from '../../handler'; + +type AdvancedTableHeaderProps = { + columns: AdvancedTableColumn[]; + query: Q; + setQuery: React.Dispatch>; +}; + +export function AdvancedTableHeader( + props: AdvancedTableHeaderProps +): JSX.Element { + return ( + + {props.columns.map((column, idx) => ( + + ))} + + ); +} + +const sortAscendingIcon = ( + +); +const sortDescendingIcon = ( + +); + +type AdvancedTableHeaderCellProps = Pick< + AdvancedTableHeaderProps, + 'query' | 'setQuery' +> & { + column: AdvancedTableColumn; +}; + +function AdvancedTableHeaderCell( + props: AdvancedTableHeaderCellProps +): JSX.Element { + const sort = props.query.sort_by; + const defaultSort = sort?.[0]; + + const headerIsDefaultSort = + defaultSort && defaultSort.name === props.column.sortField; + const isSortedAscending = + headerIsDefaultSort && + defaultSort && + defaultSort.direction === Scheduler.SortDirection.ASC; + const isSortedDescending = + headerIsDefaultSort && + defaultSort && + defaultSort.direction === Scheduler.SortDirection.DESC; + + const sortByThisColumn = () => { + // If this field is not sortable, do nothing. + if (!props.column.sortField) { + return; + } + + // Change the sort of this column. + // If not sorted at all or if sorted descending, sort ascending. If sorted ascending, sort descending. + const newSortDirection = isSortedAscending + ? Scheduler.SortDirection.DESC + : Scheduler.SortDirection.ASC; + + // Set the new sort direction. + const newSort: Scheduler.ISortField = { + name: props.column.sortField, + direction: newSortDirection + }; + + // If this field is already present in the sort list, remove it. + const oldSortList = sort || []; + const newSortList = [ + newSort, + ...oldSortList.filter(item => item.name !== props.column.sortField) + ]; + + // Sub the new sort list in to the query. + props.setQuery(query => ({ ...query, sort_by: newSortList })); + }; + + return ( + + {props.column.name} + {isSortedAscending && sortAscendingIcon} + {isSortedDescending && sortDescendingIcon} + + ); +} diff --git a/src/dag-scheduler/components/advanced-table/advanced-table.tsx b/src/dag-scheduler/components/advanced-table/advanced-table.tsx new file mode 100644 index 000000000..cb9885a6b --- /dev/null +++ b/src/dag-scheduler/components/advanced-table/advanced-table.tsx @@ -0,0 +1,325 @@ +import React, { useCallback, useEffect, useState, useMemo } from 'react'; + +import { useTheme } from '@mui/material/styles'; +import Table from '@mui/material/Table'; +import TableContainer from '@mui/material/TableContainer'; +import TableBody from '@mui/material/TableBody'; +import TablePagination, { + LabelDisplayedRowsArgs +} from '@mui/material/TablePagination'; +import Paper from '@mui/material/Paper'; + +import { Scheduler } from '../../handler'; +import { AdvancedTableHeader } from './advanced-table-header'; +import { useTranslator } from '../../hooks'; +import { Alert, TableCell, TableRow } from '@mui/material'; +import { TableFilter } from '../table-filter'; +import LinearProgress from '@mui/material/LinearProgress'; + +const PAGE_SIZE = 25; + +export type AdvancedTableColumn = { + sortField: string | null; + name: string; + filterable?: boolean; + field?: string; + valueOptions?: any[]; + valueFormatter?: (value: any) => any; + type?: 'string' | 'date' | 'singleSelect'; +}; + +type AdvancedTablePayload = + | { + next_token?: string; + total_count?: number; + } + | undefined; + +export type AdvancedTableQuery = { + max_items?: number; + next_token?: string; + sort_by?: Scheduler.ISortField[]; +}; + +/** + * P = payload (response) type, Q = query (request) type, R = row type + * + * Requires `next_token` to be defined in Q to function. + */ +type AdvancedTableProps< + P extends AdvancedTablePayload, + Q extends AdvancedTableQuery, + R +> = { + query: Q; + setQuery: React.Dispatch>; + request: (query: Q) => Promise

; + renderRow: (row: R) => JSX.Element; + extractRows: (payload: P) => R[]; + columns: AdvancedTableColumn[]; + emptyRowMessage: string; + rowFilter?: (row: R) => boolean; + pageSize?: number; + toolbarComponent?: JSX.Element; + /** + * Height of the table. If set to 'auto', this table automatically expands to + * fit the remaining height of the page when wrapped in a flex container with + * its height equal to the height of the page. Otherwise the max-height is + * manually set to whatever value is provided. Defaults to 'auto'. + */ + height?: 'auto' | string | number; +}; + +/** + * Advanced table that automatically fills remaining screen width, asynchronous + * pagination, and loading states. + */ +export function AdvancedTable< + P extends AdvancedTablePayload, + Q extends AdvancedTableQuery, + R +>(props: AdvancedTableProps): JSX.Element { + const [rows, setRows] = useState(); + const [filterFuncs, setFilterFuncs] = useState(); + const [filteredRows, setFilteredRows] = useState(); + const [nextToken, setNextToken] = useState(); + const [totalCount, setTotalCount] = useState(); + const [page, setPage] = useState(0); + const [maxPage, setMaxPage] = useState(0); + const [loading, setLoading] = useState(true); + const [displayError, setDisplayError] = useState(null); + const trans = useTranslator('jupyterlab'); + const theme = useTheme(); + + const pageSize = props.pageSize ?? PAGE_SIZE; + + const fetchInitialRows = async () => { + // reset pagination state + setPage(0); + setMaxPage(0); + + setLoading(true); + setDisplayError(null); // Cancel previous errors + + props + .request({ + ...props.query, + max_items: pageSize + }) + .then(payload => { + const rows = props.extractRows(payload); + + setLoading(false); + setRows(rows); + setNextToken(payload?.next_token); + setTotalCount(payload?.total_count); + setMaxPage(rows.length % pageSize); + }) + .catch((e: Error) => { + setDisplayError(e.message); + }); + }; + + // Fetch the initial rows asynchronously on component creation + // After setJobsQuery is called, force a reload. + useEffect(() => { + fetchInitialRows(); + }, [props.query]); + + useEffect(() => { + // set filtered rows + const filteredRows = rows?.filter(row => + filterFuncs?.length ? filterFuncs?.every(func => func(row)) : true + ); + + const totalRows = filteredRows?.length || 0; + + setPage(0); + setFilteredRows(filteredRows || []); + setTotalCount(totalRows); + setMaxPage(totalRows % pageSize); + }, [rows, filterFuncs]); + + const handelFilterUpdate = (predicates: any[]) => { + setFilterFuncs(predicates); + }; + + const fetchMoreRows = async (newPage: number) => { + // Do nothing if the next token is undefined (shouldn't happen, but required for type safety) + if (nextToken === undefined) { + return false; + } + + // Apply the custom token to the existing query parameters + setLoading(true); + setDisplayError(null); // Cancel previous errors + + props + .request({ + ...props.query, + max_items: pageSize, + next_token: nextToken + }) + .then(payload => { + setLoading(false); + const newRows = props.extractRows(payload) || []; + + if (newRows.length === 0) { + // no rows in next page -- leave page unchanged, disable next page + // button, and show an error banner + setNextToken(undefined); + setDisplayError(trans.__('Last page reached.')); + return; + } + + // otherwise, merge the two lists of jobs and keep the next token from + // the new response. + setRows(rows => [ + ...(rows || []), + ...(props.extractRows(payload) || []) + ]); + setNextToken(payload?.next_token); + setTotalCount(payload?.total_count); + setPage(newPage); + setMaxPage(newPage); + }) + .catch((e: Error) => { + setDisplayError(e.message); + }); + }; + + const renderedRows: JSX.Element[] = useMemo( + () => + (filteredRows || []) + .slice(page * pageSize, (page + 1) * pageSize) + .filter(row => (props.rowFilter ? props.rowFilter(row) : true)) + .map(row => props.renderRow(row)), + [filteredRows, props.rowFilter, props.renderRow, page, pageSize] + ); + + const handlePageChange = async (e: unknown, newPage: number) => { + // first clear any display errors + setDisplayError(null); + // if newPage <= maxPage, no need to fetch more rows + if (newPage <= maxPage) { + setPage(newPage); + + return; + } + + await fetchMoreRows(newPage); + }; + + const onLastPage = page === maxPage && nextToken === undefined; + + /** + * Renders the label to the left of the pagination buttons. + */ + const labelDisplayedRows = useCallback( + ({ from, to, count }: LabelDisplayedRowsArgs) => { + if (count === -1) { + const loadedRows = filteredRows?.length ?? 0; + if (onLastPage) { + // for some reason `to` is set incorrectly on the last page in + // server-side pagination, so we need to build the string differently + // in this case. + return trans.__('%1–%2 of %3', from, loadedRows, loadedRows); + } else { + return trans.__( + '%1–%2 of %3', + from, + to, + loadedRows + (nextToken === undefined ? '' : '+') + ); + } + } else { + return trans.__('%1–%2 of %3', from, to, count); + } + }, + [filteredRows, onLastPage, trans] + ); + + const tableDiv = ( +

+
+ + {props.toolbarComponent ? props.toolbarComponent : null} +
+ + + + + + + {loading ? : null} + + + + {renderedRows} +
+ {filteredRows?.length ? ( + + ) : ( +

+ {props.emptyRowMessage} +

+ )} +
+
+ ); + + return ( + <> + {displayError && {displayError}} + {tableDiv} + + ); +} diff --git a/src/dag-scheduler/components/advanced-table/index.tsx b/src/dag-scheduler/components/advanced-table/index.tsx new file mode 100644 index 000000000..ceebe535f --- /dev/null +++ b/src/dag-scheduler/components/advanced-table/index.tsx @@ -0,0 +1 @@ +export * from './advanced-table'; diff --git a/src/dag-scheduler/components/async-action-handler.ts b/src/dag-scheduler/components/async-action-handler.ts new file mode 100644 index 000000000..e049f1b9b --- /dev/null +++ b/src/dag-scheduler/components/async-action-handler.ts @@ -0,0 +1,151 @@ +import { Spinner } from '@jupyterlab/apputils'; +import { closeIcon, trustedIcon } from '@jupyterlab/ui-components'; +import { Widget } from '@lumino/widgets'; +import { errorIcon } from './icons'; + +type AsyncHandlerArgs = { + title: string; + tooltip?: string; + action: Promise; + successMessage?: string; + failureMessage?: string | CallableFunction; +}; + +type LoaderArgs = { + title: string; + tooltip?: string; + onClose: () => void; +}; + +class Loader extends Widget { + constructor({ title, tooltip = '', onClose }: LoaderArgs) { + super(); + + const spinnerIcon = new Spinner(); + const wrapper = document.createElement('div'); + const iconContainer = document.createElement('div'); + const messageContainer = document.createElement('div'); + const closeButton = document.createElement('button'); + + messageContainer.className = 'jp-toast-text'; + iconContainer.className = 'jp-toast-icon'; + wrapper.className = 'jp-spinner-container'; + messageContainer.title = tooltip || title; + + wrapper.appendChild(iconContainer); + wrapper.appendChild(messageContainer); + wrapper.appendChild(closeButton); + + iconContainer.appendChild(spinnerIcon.node); + closeButton.appendChild(closeIcon.element({ className: 'close-icon' })); + messageContainer.appendChild(document.createTextNode(title)); + + this.icon = iconContainer; + this.message = messageContainer; + this.node.appendChild(wrapper); + + this.closeBtn = closeButton; + this.closeBtn.style.display = 'none'; + closeButton.addEventListener('click', () => onClose()); + } + + setSuccessMessage(message: string) { + this.icon.innerHTML = ''; + this.icon.appendChild( + trustedIcon + .bindprops({ + width: '24px', + fill: 'var(--jp-accent-color2)', + stroke: 'var(--jp-accent-color2)' + }) + .element() + ); + this.message.textContent = message; + this.closeBtn.style.display = 'block'; + + return true; + } + + setFailureMessage(message: string) { + this.icon.innerHTML = ''; + this.icon.appendChild( + errorIcon + .bindprops({ + width: '20px', + stroke: 'transparent', + fill: 'var(--jp-error-color2)' + }) + .element() + ); + this.message.textContent = message; + this.closeBtn.style.display = 'block'; + + return true; + } + + private icon: HTMLDivElement; + private message: HTMLDivElement; + private closeBtn: HTMLButtonElement; +} + +export class AsyncActionHandler extends Widget { + constructor(private options: AsyncHandlerArgs) { + super(); + + const widget = new Widget(); + + this.container = document.querySelector('.jp-toast-container'); + + if (!this.container) { + this.container = document.createElement('div'); + this.container.classList.add('jp-toast-container'); + document.body.appendChild(this.container); + } + + this.loader = new Loader({ + title: options.title, + tooltip: options.tooltip, + onClose: this.close.bind(this) + }); + + this.addClass('jp-async-toast'); + widget.addClass('jp-toast-content'); + + this.node.appendChild(widget.node); + widget.node.appendChild(this.loader.node); + } + + start(): Promise { + const { action, successMessage, failureMessage } = this.options; + const onSuccess = () => + successMessage && this.loader.setSuccessMessage(successMessage); + + let onFailure; + if (typeof failureMessage === 'string') { + onFailure = () => + failureMessage && this.loader.setFailureMessage(failureMessage); + } else { + onFailure = () => { + if (failureMessage) { + return this.loader.setFailureMessage(failureMessage()); + } + }; + } + + action + .then(onSuccess) + .catch(onFailure) + .then(shouldWait => + shouldWait ? setTimeout(this.close.bind(this), 1500) : this.close() + ); + + if (this.container) { + Widget.attach(this, this.container); + } + + return action; + } + + private loader: Loader; + private container: HTMLDivElement | null; +} diff --git a/src/dag-scheduler/components/async-button.tsx b/src/dag-scheduler/components/async-button.tsx new file mode 100644 index 000000000..4691b5fdf --- /dev/null +++ b/src/dag-scheduler/components/async-button.tsx @@ -0,0 +1,55 @@ +import { Button, ButtonProps, CircularProgress } from '@mui/material'; +import React, { useEffect, useRef, useState } from 'react'; + +type Props = ButtonProps & { + onClick: (...args: any[]) => Promise; +}; + +export function AsyncButton(props: Props): JSX.Element { + const [isPending, setIsPending] = useState(false); + const mountRef = useRef(false); + + const iconProps = + props.variant === 'outlined' + ? { color: 'primary' } + : { + style: { color: 'white' } + }; + + const pendingProps = isPending + ? { + endIcon: + } + : { startIcon: props.startIcon }; + + useEffect(() => { + mountRef.current = true; + + return () => { + mountRef.current = false; + }; + }, []); + + const handleClick = async () => { + try { + mountRef.current = true; + setIsPending(true); + await props.onClick(); + } catch (error) { + console.log(error); + } + + if (mountRef.current) { + setIsPending(false); + } + }; + + return ( +