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";
94 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
99 paddingTop: theme.spacing.unit,
103 fontSize: "1.875rem",
104 color: theme.customs.colors.greyL,
107 alignSelf: "flex-start",
108 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,
120 paddingTop: theme.spacing.unit * 0.5,
121 color: theme.customs.colors.greyD,
122 fontSize: "1.875rem",
126 maxHeight: `calc(100% - ${theme.spacing.unit * 4.5}px)`,
132 verticalAlign: "bottom",
133 paddingBottom: "10px",
136 paddingRight: "25px",
141 alignItems: "flex-start",
142 flexDirection: "column",
145 color: theme.palette.primary.main,
146 textDecoration: "none",
147 overflowWrap: "break-word",
153 color: theme.palette.primary.main,
154 textDecoration: "none",
155 overflowWrap: "break-word",
162 marginBottom: theme.spacing.unit,
177 verticalAlign: "top",
178 position: "relative",
182 color: theme.customs.colors.grey700,
186 borderBottom: "none",
198 alignItems: "center",
199 justifyContent: "center",
200 backgroundColor: "#cecece",
201 borderRadius: "10px",
204 verticalAlign: "bottom",
211 export enum ProcessIOCardType {
215 export interface ProcessIOCardDataProps {
217 label: ProcessIOCardType;
218 params: ProcessIOParameter[] | null;
220 mounts?: InputCollectionMount[];
222 showParams?: boolean;
225 export interface ProcessIOCardActionProps {
226 navigateTo: (uuid: string) => void;
229 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
230 navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
233 type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
235 export const ProcessIOCard = withStyles(styles)(
255 }: ProcessIOCardProps) => {
256 const [mainProcTabState, setMainProcTabState] = useState(0);
257 const [subProcTabState, setSubProcTabState] = useState(0);
258 const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
259 setMainProcTabState(value);
261 const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
262 setSubProcTabState(value);
265 const [showImagePreview, setShowImagePreview] = useState(false);
267 const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
268 const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
270 const loading = raw === null || raw === undefined || params === null;
271 const hasRaw = !!(raw && Object.keys(raw).length > 0);
272 const hasParams = !!(params && params.length > 0);
275 const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
276 const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
280 className={classes.card}
281 data-cy="process-io-card"
284 className={classes.header}
286 content: classes.title,
287 avatar: classes.avatar,
289 avatar={<PanelIcon className={classes.iconHeader} />}
303 title={"Toggle Image Preview"}
307 data-cy="io-preview-image-toggle"
309 setShowImagePreview(!showImagePreview);
312 {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
316 {doUnMaximizePanel && panelMaximized && (
318 title={`Unmaximize ${panelName || "panel"}`}
321 <IconButton onClick={doUnMaximizePanel}>
326 {doMaximizePanel && !panelMaximized && (
328 title={`Maximize ${panelName || "panel"}`}
331 <IconButton onClick={doMaximizePanel}>
338 title={`Close ${panelName || "panel"}`}
342 disabled={panelMaximized}
343 onClick={doHidePanel}
352 <CardContent className={classes.content}>
353 {mainProcess || showParams ? (
355 {/* raw is undefined until params are loaded */}
366 {/* Once loaded, either raw or params may still be empty
367 * Raw when all params are empty
368 * Params when raw is provided by containerRequest properties but workflow mount is absent for preview
370 {!loading && (hasRaw || hasParams) && (
373 value={mainProcTabState}
374 onChange={handleMainProcTabChange}
376 className={classes.symmetricTabs}
378 {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
379 {hasParams && <Tab label="Parameters" />}
380 {!showParams && <Tab label="JSON" />}
382 {mainProcTabState === 0 && params && hasParams && (
383 <div className={classes.tableWrapper}>
386 showImagePreview={showImagePreview}
387 valueLabel={showParams ? "Default value" : "Value"}
391 {(mainProcTabState === 1 || !hasParams) && (
392 <div className={classes.tableWrapper}>
393 <ProcessIORaw data={raw} />
398 {!loading && !hasRaw && !hasParams && (
405 <DefaultView messages={["No parameters found"]} />
422 {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ? (
425 value={subProcTabState}
426 onChange={handleSubProcTabChange}
428 className={classes.symmetricTabs}
430 {hasInputMounts && <Tab label="Collections" />}
431 {hasOutputCollecton && <Tab label="Collection" />}
434 <div className={classes.tableWrapper}>
435 {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
436 {subProcTabState === 0 && hasOutputCollecton && (
439 <Typography className={classes.collectionLink}>
440 Output Collection:{" "}
442 className={classes.keepLink}
444 navigateTo(outputUuid || "");
451 <ProcessOutputCollectionFiles
453 currentItemUuid={outputUuid}
457 {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
458 <div className={classes.tableWrapper}>
459 <ProcessIORaw data={raw} />
471 <DefaultView messages={["No data to display"]} />
483 export type ProcessIOValue = {
484 display: ReactElement<any, any>;
486 collection?: ReactElement<any, any>;
490 export type ProcessIOParameter = {
493 value: ProcessIOValue[];
496 interface ProcessIOPreviewDataProps {
497 data: ProcessIOParameter[];
498 showImagePreview: boolean;
502 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
504 const ProcessIOPreview = memo(
505 withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
506 const showLabel = data.some((param: ProcessIOParameter) => param.label);
509 className={classes.tableRoot}
510 aria-label="Process IO Preview"
514 <TableCell>Name</TableCell>
515 {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
516 <TableCell>{valueLabel}</TableCell>
517 <TableCell>Collection</TableCell>
521 {data.map((param: ProcessIOParameter) => {
522 const firstVal = param.value.length > 0 ? param.value[0] : undefined;
523 const rest = param.value.slice(1);
524 const mainRowClasses = {
525 [classes.noBorderRow]: rest.length > 0,
529 <React.Fragment key={param.id}>
531 className={classNames(mainRowClasses)}
532 data-cy="process-io-param"
534 <TableCell>{param.id}</TableCell>
535 {showLabel && <TableCell>{param.label}</TableCell>}
540 showImagePreview={showImagePreview}
544 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
545 <Typography className={classes.paramValue}>{firstVal?.collection}</Typography>
548 {rest.map((val, i) => {
550 [classes.noBorderRow]: i < rest.length - 1,
551 [classes.secondaryRow]: val.secondary,
555 className={classNames(rowClasses)}
559 {showLabel && <TableCell />}
563 showImagePreview={showImagePreview}
566 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
567 <Typography className={classes.paramValue}>{val.collection}</Typography>
581 interface ProcessValuePreviewProps {
582 value: ProcessIOValue;
583 showImagePreview: boolean;
586 const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
587 <Typography className={classes.paramValue}>
588 {value.imageUrl && showImagePreview ? (
590 className={classes.imagePreview}
597 {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
598 <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>{value.display}</span>
602 interface ProcessIORawDataProps {
603 data: ProcessIOParameter[];
606 const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
607 <Paper elevation={0}>
609 lines={[JSON.stringify(data, null, 2)]}
615 interface ProcessInputMountsDataProps {
616 mounts: InputCollectionMount[];
619 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
621 const ProcessInputMounts = withStyles(styles)(
622 connect((state: RootState) => ({
624 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
626 className={classes.tableRoot}
627 aria-label="Process Input Mounts"
631 <TableCell>Path</TableCell>
632 <TableCell>Portable Data Hash</TableCell>
636 {mounts.map(mount => (
637 <TableRow key={mount.path}>
639 <pre>{mount.path}</pre>
643 to={getNavUrl(mount.pdh, auth)}
644 className={classes.keepLink}
656 type FileWithSecondaryFiles = {
657 secondaryFiles: File[];
660 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
662 case isPrimitiveOfType(input, CWLType.BOOLEAN):
663 const boolValue = (input as BooleanCommandInputParameter).value;
664 return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
665 ? [{ display: renderPrimitiveValue(boolValue, false) }]
666 : [{ display: <EmptyValue /> }];
668 case isPrimitiveOfType(input, CWLType.INT):
669 case isPrimitiveOfType(input, CWLType.LONG):
670 const intValue = (input as IntCommandInputParameter).value;
671 return intValue !== undefined &&
672 // Missing values are empty array
673 !(Array.isArray(intValue) && intValue.length === 0)
674 ? [{ display: renderPrimitiveValue(intValue, false) }]
675 : [{ display: <EmptyValue /> }];
677 case isPrimitiveOfType(input, CWLType.FLOAT):
678 case isPrimitiveOfType(input, CWLType.DOUBLE):
679 const floatValue = (input as FloatCommandInputParameter).value;
680 return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
681 ? [{ display: renderPrimitiveValue(floatValue, false) }]
682 : [{ display: <EmptyValue /> }];
684 case isPrimitiveOfType(input, CWLType.STRING):
685 const stringValue = (input as StringCommandInputParameter).value || undefined;
686 return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
687 ? [{ display: renderPrimitiveValue(stringValue, false) }]
688 : [{ display: <EmptyValue /> }];
690 case isPrimitiveOfType(input, CWLType.FILE):
691 const mainFile = (input as FileCommandInputParameter).value;
692 // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
693 const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
694 const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
695 const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
697 ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
698 : [{ display: <EmptyValue /> }];
700 case isPrimitiveOfType(input, CWLType.DIRECTORY):
701 const directory = (input as DirectoryCommandInputParameter).value;
702 return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
703 ? [directoryToProcessIOValue(directory, auth, pdh)]
704 : [{ display: <EmptyValue /> }];
706 case getEnumType(input) !== null:
707 const enumValue = (input as EnumCommandInputParameter).value;
708 return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
710 case isArrayOfType(input, CWLType.STRING):
711 const strArray = (input as StringArrayCommandInputParameter).value || [];
712 return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
714 case isArrayOfType(input, CWLType.INT):
715 case isArrayOfType(input, CWLType.LONG):
716 const intArray = (input as IntArrayCommandInputParameter).value || [];
717 return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
719 case isArrayOfType(input, CWLType.FLOAT):
720 case isArrayOfType(input, CWLType.DOUBLE):
721 const floatArray = (input as FloatArrayCommandInputParameter).value || [];
722 return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
724 case isArrayOfType(input, CWLType.FILE):
725 const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
726 const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
728 // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
729 let fileArrayValues: ProcessIOValue[] = [];
730 for (let i = 0; i < fileArrayMainFiles.length; i++) {
731 const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
732 fileArrayValues.push(
733 // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
734 ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
735 ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
739 return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
741 case isArrayOfType(input, CWLType.DIRECTORY):
742 const directories = (input as DirectoryArrayCommandInputParameter).value || [];
743 return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
746 return [{ display: <UnsupportedValue /> }];
750 const renderPrimitiveValue = (value: any, asChip: boolean) => {
751 const isObject = typeof value === "object";
756 label={String(value)}
759 <pre key={value}>{String(value)}</pre>
762 return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
767 * @returns keep url without keep: prefix
769 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
770 const isKeepUrl = file.location?.startsWith("keep:") || false;
771 const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
772 return keepUrl || "";
775 interface KeepUrlProps {
777 res: File | Directory;
781 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
782 const keepUrl = getKeepUrl(res, pdh);
783 return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
786 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
787 const pdhUrl = getResourcePdhUrl(res, pdh);
788 // Passing a pdh always returns a relative wb2 collection url
789 const pdhWbPath = getNavUrl(pdhUrl, auth);
790 return pdhUrl && pdhWbPath ? (
791 <Tooltip title={"View collection in Workbench"}>
794 className={classes.keepLink}
804 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
805 const keepUrl = getKeepUrl(res, pdh);
806 const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
807 const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
809 const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
810 return keepUrlPathNav ? (
811 <Tooltip title={"View in keep-web"}>
813 className={classes.keepLink}
814 href={keepUrlPathNav}
816 rel="noopener noreferrer"
826 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
827 let keepUrl = getKeepUrl(file, pdh);
828 return getInlineFileUrl(
829 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
830 auth.config.keepWebServiceUrl,
831 auth.config.keepWebInlineServiceUrl
835 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
836 const keepUrl = getKeepUrl(file, pdh);
837 return getInlineFileUrl(
838 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
839 auth.config.keepWebServiceUrl,
840 auth.config.keepWebInlineServiceUrl
844 const isFileImage = (basename?: string): boolean => {
845 return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
848 const isFileUrl = (location?: string): boolean =>
849 !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
851 const normalizeDirectoryLocation = (directory: Directory): Directory => {
852 if (!directory.location) {
857 location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
861 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
862 if (isExternalValue(directory)) {
863 return { display: <UnsupportedValue /> };
866 const normalizedDirectory = normalizeDirectoryLocation(directory);
871 res={normalizedDirectory}
878 res={normalizedDirectory}
885 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
886 if (isExternalValue(file)) {
887 return { display: <UnsupportedValue /> };
890 if (isFileUrl(file.location)) {
904 const resourcePdh = getResourcePdhUrl(file, pdh);
914 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
916 resourcePdh !== mainFilePdh ? (
928 const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
930 export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
932 const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
934 const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
937 label={"Cannot display value"}
941 const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
942 <span className={classes.imagePlaceholder}>