1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React, { ReactElement, 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,
50 } from "models/workflow";
51 import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
52 import { File } from "models/workflow";
53 import { getInlineFileUrl } from "views-components/context-menu/actions/helpers";
54 import { AuthState } from "store/auth/auth-reducer";
55 import mime from "mime";
56 import { DefaultView } from "components/default-view/default-view";
57 import { getNavUrl } from "routes/routes";
58 import { Link as RouterLink } from "react-router-dom";
59 import { Link as MuiLink } from "@material-ui/core";
60 import { InputCollectionMount } from "store/processes/processes-actions";
61 import { connect } from "react-redux";
62 import { RootState } from "store/store";
63 import { ProcessOutputCollectionFiles } from "./process-output-collection-files";
64 import { Process } from "store/processes/process";
65 import { navigateTo } from "store/navigation/navigation-action";
66 import classNames from "classnames";
67 import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
68 import { KEEP_URL_REGEX } from "models/resource";
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,
110 height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
111 padding: theme.spacing.unit * 1.0,
114 paddingBottom: theme.spacing.unit * 1,
119 paddingTop: theme.spacing.unit * 0.5,
120 color: theme.customs.colors.greyD,
121 fontSize: "1.875rem",
125 maxHeight: `calc(100% - ${theme.spacing.unit * 4.5}px)`,
131 verticalAlign: "bottom",
132 paddingBottom: "10px",
135 paddingRight: "25px",
140 alignItems: "flex-start",
141 flexDirection: "column",
144 color: theme.palette.primary.main,
145 textDecoration: "none",
146 overflowWrap: "break-word",
152 color: theme.palette.primary.main,
153 textDecoration: "none",
154 overflowWrap: "break-word",
161 marginBottom: theme.spacing.unit,
176 verticalAlign: "top",
177 position: "relative",
181 color: theme.customs.colors.grey700,
185 borderBottom: "none",
197 alignItems: "center",
198 justifyContent: "center",
199 backgroundColor: "#cecece",
200 borderRadius: "10px",
203 verticalAlign: "bottom",
210 export enum ProcessIOCardType {
214 export interface ProcessIOCardDataProps {
216 label: ProcessIOCardType;
217 params: ProcessIOParameter[] | null;
219 mounts?: InputCollectionMount[];
221 showParams?: boolean;
224 export interface ProcessIOCardActionProps {
225 navigateTo: (uuid: string) => void;
228 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
229 navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
232 type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
234 export const ProcessIOCard = withStyles(styles)(
254 }: ProcessIOCardProps) => {
255 const [mainProcTabState, setMainProcTabState] = useState(0);
256 const [subProcTabState, setSubProcTabState] = useState(0);
257 const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
258 setMainProcTabState(value);
260 const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
261 setSubProcTabState(value);
264 const [showImagePreview, setShowImagePreview] = useState(false);
266 const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
267 const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
269 const loading = raw === null || raw === undefined || params === null;
270 const hasRaw = !!(raw && Object.keys(raw).length > 0);
271 const hasParams = !!(params && params.length > 0);
274 const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
275 const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
279 className={classes.card}
280 data-cy="process-io-card">
283 className={classes.header}
285 content: classes.title,
286 avatar: classes.avatar,
288 avatar={<PanelIcon className={classes.iconHeader} />}
301 title={"Toggle Image Preview"}
302 disableFocusListener>
304 data-cy="io-preview-image-toggle"
306 setShowImagePreview(!showImagePreview);
308 {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
312 {doUnMaximizePanel && panelMaximized && (
314 title={`Unmaximize ${panelName || "panel"}`}
315 disableFocusListener>
316 <IconButton onClick={doUnMaximizePanel}>
321 {doMaximizePanel && !panelMaximized && (
323 title={`Maximize ${panelName || "panel"}`}
324 disableFocusListener>
325 <IconButton onClick={doMaximizePanel}>
332 title={`Close ${panelName || "panel"}`}
333 disableFocusListener>
335 disabled={panelMaximized}
336 onClick={doHidePanel}>
344 <CardContent className={classes.content}>
345 {mainProcess || showParams ? (
347 {/* raw is undefined until params are loaded */}
357 {/* Once loaded, either raw or params may still be empty
358 * Raw when all params are empty
359 * Params when raw is provided by containerRequest properties but workflow mount is absent for preview
361 {!loading && (hasRaw || hasParams) && (
364 value={mainProcTabState}
365 onChange={handleMainProcTabChange}
367 className={classes.symmetricTabs}>
368 {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
369 {hasParams && <Tab label="Parameters" />}
370 {!showParams && <Tab label="JSON" />}
372 {mainProcTabState === 0 && params && hasParams && (
373 <div className={classes.tableWrapper}>
376 showImagePreview={showImagePreview}
377 valueLabel={showParams ? "Default value" : "Value"}
381 {(mainProcTabState === 1 || !hasParams) && (
382 <div className={classes.tableWrapper}>
383 <ProcessIORaw data={raw} />
388 {!loading && !hasRaw && !hasParams && (
394 <DefaultView messages={["No parameters found"]} />
410 {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ? (
413 value={subProcTabState}
414 onChange={handleSubProcTabChange}
416 className={classes.symmetricTabs}>
417 {hasInputMounts && <Tab label="Collections" />}
418 {hasOutputCollecton && <Tab label="Collection" />}
421 <div className={classes.tableWrapper}>
422 {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
423 {subProcTabState === 0 && hasOutputCollecton && (
426 <Typography className={classes.collectionLink}>
427 Output Collection:{" "}
429 className={classes.keepLink}
431 navigateTo(outputUuid || "");
437 <ProcessOutputCollectionFiles
439 currentItemUuid={outputUuid}
443 {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
444 <div className={classes.tableWrapper}>
445 <ProcessIORaw data={raw} />
456 <DefaultView messages={["No data to display"]} />
468 export type ProcessIOValue = {
469 display: ReactElement<any, any>;
471 collection?: ReactElement<any, any>;
475 export type ProcessIOParameter = {
478 value: ProcessIOValue[];
481 interface ProcessIOPreviewDataProps {
482 data: ProcessIOParameter[];
483 showImagePreview: boolean;
487 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
489 const ProcessIOPreview = withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
490 const showLabel = data.some((param: ProcessIOParameter) => param.label);
493 className={classes.tableRoot}
494 aria-label="Process IO Preview">
497 <TableCell>Name</TableCell>
498 {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
499 <TableCell>{valueLabel}</TableCell>
500 <TableCell>Collection</TableCell>
504 {data.map((param: ProcessIOParameter) => {
505 const firstVal = param.value.length > 0 ? param.value[0] : undefined;
506 const rest = param.value.slice(1);
507 const mainRowClasses = {
508 [classes.noBorderRow]: rest.length > 0,
512 <React.Fragment key={param.id}>
514 className={classNames(mainRowClasses)}
515 data-cy="process-io-param">
516 <TableCell>{param.id}</TableCell>
517 {showLabel && <TableCell>{param.label}</TableCell>}
522 showImagePreview={showImagePreview}
526 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
527 <Typography className={classes.paramValue}>{firstVal?.collection}</Typography>
530 {rest.map((val, i) => {
532 [classes.noBorderRow]: i < rest.length - 1,
533 [classes.secondaryRow]: val.secondary,
537 className={classNames(rowClasses)}
540 {showLabel && <TableCell />}
544 showImagePreview={showImagePreview}
547 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
548 <Typography className={classes.paramValue}>{val.collection}</Typography>
561 interface ProcessValuePreviewProps {
562 value: ProcessIOValue;
563 showImagePreview: boolean;
566 const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
567 <Typography className={classes.paramValue}>
568 {value.imageUrl && showImagePreview ? (
570 className={classes.imagePreview}
577 {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
578 <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>{value.display}</span>
582 interface ProcessIORawDataProps {
583 data: ProcessIOParameter[];
586 const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
587 <Paper elevation={0}>
589 lines={[JSON.stringify(data, null, 2)]}
595 interface ProcessInputMountsDataProps {
596 mounts: InputCollectionMount[];
599 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
601 const ProcessInputMounts = withStyles(styles)(
602 connect((state: RootState) => ({
604 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
606 className={classes.tableRoot}
607 aria-label="Process Input Mounts">
610 <TableCell>Path</TableCell>
611 <TableCell>Portable Data Hash</TableCell>
615 {mounts.map(mount => (
616 <TableRow key={mount.path}>
618 <pre>{mount.path}</pre>
622 to={getNavUrl(mount.pdh, auth)}
623 className={classes.keepLink}>
634 type FileWithSecondaryFiles = {
635 secondaryFiles: File[];
638 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
640 case isPrimitiveOfType(input, CWLType.BOOLEAN):
641 const boolValue = (input as BooleanCommandInputParameter).value;
642 return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
643 ? [{ display: renderPrimitiveValue(boolValue, false) }]
644 : [{ display: <EmptyValue /> }];
646 case isPrimitiveOfType(input, CWLType.INT):
647 case isPrimitiveOfType(input, CWLType.LONG):
648 const intValue = (input as IntCommandInputParameter).value;
649 return intValue !== undefined &&
650 // Missing values are empty array
651 !(Array.isArray(intValue) && intValue.length === 0)
652 ? [{ display: renderPrimitiveValue(intValue, false) }]
653 : [{ display: <EmptyValue /> }];
655 case isPrimitiveOfType(input, CWLType.FLOAT):
656 case isPrimitiveOfType(input, CWLType.DOUBLE):
657 const floatValue = (input as FloatCommandInputParameter).value;
658 return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
659 ? [{ display: renderPrimitiveValue(floatValue, false) }]
660 : [{ display: <EmptyValue /> }];
662 case isPrimitiveOfType(input, CWLType.STRING):
663 const stringValue = (input as StringCommandInputParameter).value || undefined;
664 return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
665 ? [{ display: renderPrimitiveValue(stringValue, false) }]
666 : [{ display: <EmptyValue /> }];
668 case isPrimitiveOfType(input, CWLType.FILE):
669 const mainFile = (input as FileCommandInputParameter).value;
670 // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
671 const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
672 const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
673 const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
675 ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
676 : [{ display: <EmptyValue /> }];
678 case isPrimitiveOfType(input, CWLType.DIRECTORY):
679 const directory = (input as DirectoryCommandInputParameter).value;
680 return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
681 ? [directoryToProcessIOValue(directory, auth, pdh)]
682 : [{ display: <EmptyValue /> }];
684 case typeof input.type === "object" && !(input.type instanceof Array) && input.type.type === "enum":
685 const enumValue = (input as EnumCommandInputParameter).value;
686 return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
688 case isArrayOfType(input, CWLType.STRING):
689 const strArray = (input as StringArrayCommandInputParameter).value || [];
690 return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
692 case isArrayOfType(input, CWLType.INT):
693 case isArrayOfType(input, CWLType.LONG):
694 const intArray = (input as IntArrayCommandInputParameter).value || [];
695 return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
697 case isArrayOfType(input, CWLType.FLOAT):
698 case isArrayOfType(input, CWLType.DOUBLE):
699 const floatArray = (input as FloatArrayCommandInputParameter).value || [];
700 return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
702 case isArrayOfType(input, CWLType.FILE):
703 const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
704 const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
706 // Convert each main file into separate arrays of ProcessIOValue to preserve secondaryFile grouping
707 const fileArrayValues = fileArrayMainFiles
708 .map((mainFile: File, i): ProcessIOValue[] => {
709 const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
711 // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
712 ...(mainFile ? [fileToProcessIOValue(mainFile, false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
713 ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh)),
715 // Reduce each mainFile/secondaryFile group into single array preserving ordering
717 .reduce((acc: ProcessIOValue[], mainFile: ProcessIOValue[]) => acc.concat(mainFile), []);
719 return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
721 case isArrayOfType(input, CWLType.DIRECTORY):
722 const directories = (input as DirectoryArrayCommandInputParameter).value || [];
723 return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
726 return [{ display: <UnsupportedValue /> }];
730 const renderPrimitiveValue = (value: any, asChip: boolean) => {
731 const isObject = typeof value === "object";
736 label={String(value)}
739 <pre key={value}>{String(value)}</pre>
742 return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
747 * @returns keep url without keep: prefix
749 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
750 const isKeepUrl = file.location?.startsWith("keep:") || false;
751 const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
752 return keepUrl || "";
755 interface KeepUrlProps {
757 res: File | Directory;
761 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
762 const keepUrl = getKeepUrl(res, pdh);
763 return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
766 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
767 const pdhUrl = getResourcePdhUrl(res, pdh);
768 // Passing a pdh always returns a relative wb2 collection url
769 const pdhWbPath = getNavUrl(pdhUrl, auth);
770 return pdhUrl && pdhWbPath ? (
771 <Tooltip title={"View collection in Workbench"}>
774 className={classes.keepLink}>
783 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
784 const keepUrl = getKeepUrl(res, pdh);
785 const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
786 const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
788 const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
789 return keepUrlPathNav ? (
790 <Tooltip title={"View in keep-web"}>
792 className={classes.keepLink}
793 href={keepUrlPathNav}
795 rel="noopener noreferrer">
804 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
805 let keepUrl = getKeepUrl(file, pdh);
806 return getInlineFileUrl(
807 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
808 auth.config.keepWebServiceUrl,
809 auth.config.keepWebInlineServiceUrl
813 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
814 const keepUrl = getKeepUrl(file, pdh);
815 return getInlineFileUrl(
816 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
817 auth.config.keepWebServiceUrl,
818 auth.config.keepWebInlineServiceUrl
822 const isFileImage = (basename?: string): boolean => {
823 return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
826 const isFileUrl = (location?: string): boolean =>
827 !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
829 const normalizeDirectoryLocation = (directory: Directory): Directory => {
830 if (!directory.location) {
835 location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
839 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
840 if (isExternalValue(directory)) {
841 return { display: <UnsupportedValue /> };
844 const normalizedDirectory = normalizeDirectoryLocation(directory);
849 res={normalizedDirectory}
856 res={normalizedDirectory}
863 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
864 if (isExternalValue(file)) {
865 return { display: <UnsupportedValue /> };
868 if (isFileUrl(file.location)) {
881 const resourcePdh = getResourcePdhUrl(file, pdh);
891 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
893 resourcePdh !== mainFilePdh ? (
905 const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
907 export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
909 const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
911 const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
914 label={"Cannot display value"}
918 const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
919 <span className={classes.imagePlaceholder}>