1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React, { ReactElement, memo, useState } from "react";
6 import { Dispatch } from "redux";
28 } from "@material-ui/core";
29 import { ArvadosTheme } from "common/custom-theme";
30 import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon";
31 import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
33 BooleanCommandInputParameter,
34 CommandInputParameter,
37 DirectoryArrayCommandInputParameter,
38 DirectoryCommandInputParameter,
39 EnumCommandInputParameter,
40 FileArrayCommandInputParameter,
41 FileCommandInputParameter,
42 FloatArrayCommandInputParameter,
43 FloatCommandInputParameter,
44 IntArrayCommandInputParameter,
45 IntCommandInputParameter,
48 StringArrayCommandInputParameter,
49 StringCommandInputParameter,
51 } from "models/workflow";
52 import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
53 import { File } from "models/workflow";
54 import { getInlineFileUrl } from "views-components/context-menu/actions/helpers";
55 import { AuthState } from "store/auth/auth-reducer";
56 import mime from "mime";
57 import { DefaultView } from "components/default-view/default-view";
58 import { getNavUrl } from "routes/routes";
59 import { Link as RouterLink } from "react-router-dom";
60 import { Link as MuiLink } from "@material-ui/core";
61 import { InputCollectionMount } from "store/processes/processes-actions";
62 import { connect } from "react-redux";
63 import { RootState } from "store/store";
64 import { ProcessOutputCollectionFiles } from "./process-output-collection-files";
65 import { Process } from "store/processes/process";
66 import { navigateTo } from "store/navigation/navigation-action";
67 import classNames from "classnames";
68 import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
69 import { KEEP_URL_REGEX } from "models/resource";
70 import { FixedSizeList } from 'react-window';
71 import AutoSizer from "react-virtualized-auto-sizer";
93 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
98 paddingTop: theme.spacing.unit,
102 fontSize: "1.875rem",
103 color: theme.customs.colors.greyL,
106 alignSelf: "flex-start",
107 paddingTop: theme.spacing.unit * 0.5,
111 height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
112 padding: theme.spacing.unit * 1.0,
115 paddingBottom: theme.spacing.unit * 1,
121 paddingTop: theme.spacing.unit * 0.5,
122 color: theme.customs.colors.greyD,
123 fontSize: "1.875rem",
125 // Applies to each tab's content
128 maxHeight: `calc(100% - ${theme.spacing.unit * 3}px)`,
130 // Use flexbox to keep scrolling at the virtual list level
132 flexDirection: "column",
133 alignItems: "start", // Prevents scroll bars at different levels in json tab
137 flexDirection: "column",
144 padding: "4px 25px 10px",
148 height: "100vh", // Must be constrained by panel maxHeight
150 // Flex header/body rows
151 "& thead tr, & > tbody tr": {
153 // Flex header/body cells
160 // Column width overrides
161 "& th:nth-last-of-type(1), & td:nth-last-of-type(1)": {
167 padding: "4px 25px 4px",
168 overflow: "auto hidden",
170 flexDirection: "row",
171 alignItems: "center",
172 whiteSpace: "nowrap",
178 verticalAlign: "bottom",
179 paddingBottom: "10px",
182 paddingRight: "25px",
185 // Virtual list row styles
190 paddingBottom: "2px",
196 alignItems: "center",
197 flexDirection: "row",
210 whiteSpace: "nowrap",
216 textOverflow: "ellipsis",
220 color: theme.palette.primary.main,
221 textDecoration: "none",
222 // Overflow wrap for mounts table
223 overflowWrap: "break-word",
226 // Output collection tab link
230 color: theme.palette.primary.main,
231 textDecoration: "none",
232 overflowWrap: "break-word",
240 color: theme.customs.colors.grey700,
244 borderBottom: "none",
246 paddingBottom: "2px",
257 export enum ProcessIOCardType {
258 INPUT = "Input Parameters",
259 OUTPUT = "Output Parameters",
261 export interface ProcessIOCardDataProps {
263 label: ProcessIOCardType;
264 params: ProcessIOParameter[] | null;
266 mounts?: InputCollectionMount[];
268 forceShowParams?: boolean;
271 export interface ProcessIOCardActionProps {
272 navigateTo: (uuid: string) => void;
275 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
276 navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
279 type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
281 export const ProcessIOCard = withStyles(styles)(
301 }: ProcessIOCardProps) => {
302 const [mainProcTabState, setMainProcTabState] = useState(0);
303 const [subProcTabState, setSubProcTabState] = useState(0);
304 const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
305 setMainProcTabState(value);
307 const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
308 setSubProcTabState(value);
311 const [showImagePreview, setShowImagePreview] = useState(false);
313 const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
314 const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
315 const showParamTable = mainProcess || forceShowParams;
317 const loading = raw === null || raw === undefined || params === null;
319 const hasRaw = !!(raw && Object.keys(raw).length > 0);
320 const hasParams = !!(params && params.length > 0);
321 // isRawLoaded allows subprocess panel to display raw even if it's {}
322 const isRawLoaded = !!(raw && Object.keys(raw).length >= 0);
325 const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
326 const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
327 // Subprocess should not show loading if hasOutputCollection or hasInputMounts
328 const subProcessLoading = loading && !hasOutputCollecton && !hasInputMounts;
332 className={classes.card}
333 data-cy="process-io-card"
336 className={classes.header}
338 content: classes.title,
339 avatar: classes.avatar,
341 avatar={<PanelIcon className={classes.iconHeader} />}
355 title={"Toggle Image Preview"}
359 data-cy="io-preview-image-toggle"
361 setShowImagePreview(!showImagePreview);
364 {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
368 {doUnMaximizePanel && panelMaximized && (
370 title={`Unmaximize ${panelName || "panel"}`}
373 <IconButton onClick={doUnMaximizePanel}>
378 {doMaximizePanel && !panelMaximized && (
380 title={`Maximize ${panelName || "panel"}`}
383 <IconButton onClick={doMaximizePanel}>
390 title={`Close ${panelName || "panel"}`}
394 disabled={panelMaximized}
395 onClick={doHidePanel}
404 <CardContent className={classes.content}>
407 {/* raw is undefined until params are loaded */}
418 {/* Once loaded, either raw or params may still be empty
419 * Raw when all params are empty
420 * Params when raw is provided by containerRequest properties but workflow mount is absent for preview
422 {!loading && (hasRaw || hasParams) && (
425 value={mainProcTabState}
426 onChange={handleMainProcTabChange}
428 className={classes.symmetricTabs}
430 {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
431 {hasParams && <Tab label="Parameters" />}
432 {!forceShowParams && <Tab label="JSON" />}
433 {hasOutputCollecton && <Tab label="Collection" />}
435 {mainProcTabState === 0 && params && hasParams && (
436 <div className={classes.tableWrapper}>
439 showImagePreview={showImagePreview}
440 valueLabel={forceShowParams ? "Default value" : "Value"}
444 {(mainProcTabState === 1 || !hasParams) && (
445 <div className={classes.tableWrapper}>
446 <ProcessIORaw data={raw} />
449 {mainProcTabState === 2 && hasOutputCollecton && (
452 <Typography className={classes.collectionLink}>
453 Output Collection:{" "}
455 className={classes.keepLink}
457 navigateTo(outputUuid || "");
464 <ProcessOutputCollectionFiles
466 currentItemUuid={outputUuid}
473 {!loading && !hasRaw && !hasParams && (
480 <DefaultView messages={["No parameters found"]} />
487 {subProcessLoading ? (
496 ) : !subProcessLoading && (hasInputMounts || hasOutputCollecton || isRawLoaded) ? (
499 value={subProcTabState}
500 onChange={handleSubProcTabChange}
502 className={classes.symmetricTabs}
504 {hasInputMounts && <Tab label="Collections" />}
505 {hasOutputCollecton && <Tab label="Collection" />}
506 {isRawLoaded && <Tab label="JSON" />}
508 <div className={classes.tableWrapper}>
509 {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
510 {subProcTabState === 0 && hasOutputCollecton && (
513 <Typography className={classes.collectionLink}>
514 Output Collection:{" "}
516 className={classes.keepLink}
518 navigateTo(outputUuid || "");
525 <ProcessOutputCollectionFiles
527 currentItemUuid={outputUuid}
531 {isRawLoaded && (subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
532 <div className={classes.tableWrapper}>
533 <ProcessIORaw data={raw} />
545 <DefaultView messages={["No data to display"]} />
557 export type ProcessIOValue = {
558 display: ReactElement<any, any>;
560 collection?: ReactElement<any, any>;
564 export type ProcessIOParameter = {
567 value: ProcessIOValue;
570 interface ProcessIOPreviewDataProps {
571 data: ProcessIOParameter[];
572 showImagePreview: boolean;
576 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
578 const ProcessIOPreview = memo(
579 withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
580 const showLabel = data.some((param: ProcessIOParameter) => param.label);
582 const hasMoreValues = (index: number) => (
583 data[index+1] && !(data[index+1].id || data[index+1].label)
586 const RenderRow = ({index, style}) => {
587 const param = data[index];
590 [classes.noBorderRow]: hasMoreValues(index),
591 [classes.rowStyles]: true,
594 return <TableRow style={style} className={classNames(rowClasses)}>
595 <TableCell>{param.id}</TableCell>
596 {showLabel && <TableCell>{param.label}</TableCell>}
600 showImagePreview={showImagePreview}
604 <Typography className={classes.valueWrapper}>
605 <span className={classes.value}>
606 {param.value.collection}
615 className={classes.paramTableRoot}
616 aria-label="Process IO Preview"
620 <TableCell>Name</TableCell>
621 {showLabel && <TableCell>Label</TableCell>}
622 <TableCell>{valueLabel}</TableCell>
623 <TableCell>Collection</TableCell>
628 {({ height, width }) =>
631 itemCount={data.length}
645 interface ProcessValuePreviewProps {
646 value: ProcessIOValue;
647 showImagePreview: boolean;
650 const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
651 <Typography className={classes.valueWrapper}>
652 <span className={classNames(classes.value, value.secondary && classes.secondaryVal)}>{value.display}</span>
656 interface ProcessIORawDataProps {
657 data: ProcessIOParameter[];
660 const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
661 <Paper elevation={0} style={{width: "100%"}}>
663 lines={[JSON.stringify(data, null, 2)]}
669 interface ProcessInputMountsDataProps {
670 mounts: InputCollectionMount[];
673 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
675 const ProcessInputMounts = withStyles(styles)(
676 connect((state: RootState) => ({
678 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
680 className={classes.mountsTableRoot}
681 aria-label="Process Input Mounts"
685 <TableCell>Path</TableCell>
686 <TableCell>Portable Data Hash</TableCell>
690 {mounts.map(mount => (
691 <TableRow key={mount.path}>
693 <pre>{mount.path}</pre>
697 to={getNavUrl(mount.pdh, auth)}
698 className={classes.keepLink}
710 type FileWithSecondaryFiles = {
711 secondaryFiles: File[];
714 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
716 case isPrimitiveOfType(input, CWLType.BOOLEAN):
717 const boolValue = (input as BooleanCommandInputParameter).value;
718 return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
719 ? [{ display: renderPrimitiveValue(boolValue, false) }]
720 : [{ display: <EmptyValue /> }];
722 case isPrimitiveOfType(input, CWLType.INT):
723 case isPrimitiveOfType(input, CWLType.LONG):
724 const intValue = (input as IntCommandInputParameter).value;
725 return intValue !== undefined &&
726 // Missing values are empty array
727 !(Array.isArray(intValue) && intValue.length === 0)
728 ? [{ display: renderPrimitiveValue(intValue, false) }]
729 : [{ display: <EmptyValue /> }];
731 case isPrimitiveOfType(input, CWLType.FLOAT):
732 case isPrimitiveOfType(input, CWLType.DOUBLE):
733 const floatValue = (input as FloatCommandInputParameter).value;
734 return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
735 ? [{ display: renderPrimitiveValue(floatValue, false) }]
736 : [{ display: <EmptyValue /> }];
738 case isPrimitiveOfType(input, CWLType.STRING):
739 const stringValue = (input as StringCommandInputParameter).value || undefined;
740 return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
741 ? [{ display: renderPrimitiveValue(stringValue, false) }]
742 : [{ display: <EmptyValue /> }];
744 case isPrimitiveOfType(input, CWLType.FILE):
745 const mainFile = (input as FileCommandInputParameter).value;
746 // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
747 const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
748 const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
749 const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
751 ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
752 : [{ display: <EmptyValue /> }];
754 case isPrimitiveOfType(input, CWLType.DIRECTORY):
755 const directory = (input as DirectoryCommandInputParameter).value;
756 return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
757 ? [directoryToProcessIOValue(directory, auth, pdh)]
758 : [{ display: <EmptyValue /> }];
760 case getEnumType(input) !== null:
761 const enumValue = (input as EnumCommandInputParameter).value;
762 return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
764 case isArrayOfType(input, CWLType.STRING):
765 const strArray = (input as StringArrayCommandInputParameter).value || [];
766 return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
768 case isArrayOfType(input, CWLType.INT):
769 case isArrayOfType(input, CWLType.LONG):
770 const intArray = (input as IntArrayCommandInputParameter).value || [];
771 return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
773 case isArrayOfType(input, CWLType.FLOAT):
774 case isArrayOfType(input, CWLType.DOUBLE):
775 const floatArray = (input as FloatArrayCommandInputParameter).value || [];
776 return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
778 case isArrayOfType(input, CWLType.FILE):
779 const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
780 const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
782 // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
783 let fileArrayValues: ProcessIOValue[] = [];
784 for (let i = 0; i < fileArrayMainFiles.length; i++) {
785 const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
786 fileArrayValues.push(
787 // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
788 ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
789 ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
793 return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
795 case isArrayOfType(input, CWLType.DIRECTORY):
796 const directories = (input as DirectoryArrayCommandInputParameter).value || [];
797 return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
800 return [{ display: <UnsupportedValue /> }];
804 const renderPrimitiveValue = (value: any, asChip: boolean) => {
805 const isObject = typeof value === "object";
810 label={String(value)}
813 <pre key={value}>{String(value)}</pre>
816 return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
821 * @returns keep url without keep: prefix
823 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
824 const isKeepUrl = file.location?.startsWith("keep:") || false;
825 const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
826 return keepUrl || "";
829 interface KeepUrlProps {
831 res: File | Directory;
835 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
836 const keepUrl = getKeepUrl(res, pdh);
837 return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
840 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
841 const pdhUrl = getResourcePdhUrl(res, pdh);
842 // Passing a pdh always returns a relative wb2 collection url
843 const pdhWbPath = getNavUrl(pdhUrl, auth);
844 return pdhUrl && pdhWbPath ? (
845 <Tooltip title={"View collection in Workbench"}>
848 className={classes.keepLink}
858 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
859 const keepUrl = getKeepUrl(res, pdh);
860 const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
861 const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
863 const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
864 return keepUrlPathNav ? (
865 <Tooltip title={"View in keep-web"}>
867 className={classes.keepLink}
868 href={keepUrlPathNav}
880 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
881 let keepUrl = getKeepUrl(file, pdh);
882 return getInlineFileUrl(
883 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
884 auth.config.keepWebServiceUrl,
885 auth.config.keepWebInlineServiceUrl
889 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
890 const keepUrl = getKeepUrl(file, pdh);
891 return getInlineFileUrl(
892 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
893 auth.config.keepWebServiceUrl,
894 auth.config.keepWebInlineServiceUrl
898 const isFileImage = (basename?: string): boolean => {
899 return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
902 const isFileUrl = (location?: string): boolean =>
903 !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
905 const normalizeDirectoryLocation = (directory: Directory): Directory => {
906 if (!directory.location) {
911 location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
915 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
916 if (isExternalValue(directory)) {
917 return { display: <UnsupportedValue /> };
920 const normalizedDirectory = normalizeDirectoryLocation(directory);
925 res={normalizedDirectory}
932 res={normalizedDirectory}
939 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
940 if (isExternalValue(file)) {
941 return { display: <UnsupportedValue /> };
944 if (isFileUrl(file.location)) {
959 const resourcePdh = getResourcePdhUrl(file, pdh);
969 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
971 resourcePdh !== mainFilePdh ? (
983 const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
985 export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
987 const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
989 const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
992 label={"Cannot display value"}