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 forceShowParams?: 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);
269 const showParamTable = mainProcess || forceShowParams;
271 const loading = raw === null || raw === undefined || params === null;
273 const hasRaw = !!(raw && Object.keys(raw).length > 0);
274 const hasParams = !!(params && params.length > 0);
275 // isRawLoaded allows subprocess panel to display raw even if it's {}
276 const isRawLoaded = !!(raw && Object.keys(raw).length >= 0);
279 const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
280 const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
281 // Subprocess should not show loading if hasOutputCollection or hasInputMounts
282 const subProcessLoading = loading && !hasOutputCollecton && !hasInputMounts;
286 className={classes.card}
287 data-cy="process-io-card"
290 className={classes.header}
292 content: classes.title,
293 avatar: classes.avatar,
295 avatar={<PanelIcon className={classes.iconHeader} />}
309 title={"Toggle Image Preview"}
313 data-cy="io-preview-image-toggle"
315 setShowImagePreview(!showImagePreview);
318 {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
322 {doUnMaximizePanel && panelMaximized && (
324 title={`Unmaximize ${panelName || "panel"}`}
327 <IconButton onClick={doUnMaximizePanel}>
332 {doMaximizePanel && !panelMaximized && (
334 title={`Maximize ${panelName || "panel"}`}
337 <IconButton onClick={doMaximizePanel}>
344 title={`Close ${panelName || "panel"}`}
348 disabled={panelMaximized}
349 onClick={doHidePanel}
358 <CardContent className={classes.content}>
361 {/* raw is undefined until params are loaded */}
372 {/* Once loaded, either raw or params may still be empty
373 * Raw when all params are empty
374 * Params when raw is provided by containerRequest properties but workflow mount is absent for preview
376 {!loading && (hasRaw || hasParams) && (
379 value={mainProcTabState}
380 onChange={handleMainProcTabChange}
382 className={classes.symmetricTabs}
384 {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
385 {hasParams && <Tab label="Parameters" />}
386 {!forceShowParams && <Tab label="JSON" />}
388 {mainProcTabState === 0 && params && hasParams && (
389 <div className={classes.tableWrapper}>
392 showImagePreview={showImagePreview}
393 valueLabel={forceShowParams ? "Default value" : "Value"}
397 {(mainProcTabState === 1 || !hasParams) && (
398 <div className={classes.tableWrapper}>
399 <ProcessIORaw data={raw} />
404 {!loading && !hasRaw && !hasParams && (
411 <DefaultView messages={["No parameters found"]} />
418 {subProcessLoading ? (
427 ) : !subProcessLoading && (hasInputMounts || hasOutputCollecton || isRawLoaded) ? (
430 value={subProcTabState}
431 onChange={handleSubProcTabChange}
433 className={classes.symmetricTabs}
435 {hasInputMounts && <Tab label="Collections" />}
436 {hasOutputCollecton && <Tab label="Collection" />}
437 {isRawLoaded && <Tab label="JSON" />}
439 <div className={classes.tableWrapper}>
440 {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
441 {subProcTabState === 0 && hasOutputCollecton && (
444 <Typography className={classes.collectionLink}>
445 Output Collection:{" "}
447 className={classes.keepLink}
449 navigateTo(outputUuid || "");
456 <ProcessOutputCollectionFiles
458 currentItemUuid={outputUuid}
462 {isRawLoaded && (subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
463 <div className={classes.tableWrapper}>
464 <ProcessIORaw data={raw} />
476 <DefaultView messages={["No data to display"]} />
488 export type ProcessIOValue = {
489 display: ReactElement<any, any>;
491 collection?: ReactElement<any, any>;
495 export type ProcessIOParameter = {
498 value: ProcessIOValue[];
501 interface ProcessIOPreviewDataProps {
502 data: ProcessIOParameter[];
503 showImagePreview: boolean;
507 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
509 const ProcessIOPreview = memo(
510 withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
511 const showLabel = data.some((param: ProcessIOParameter) => param.label);
514 className={classes.tableRoot}
515 aria-label="Process IO Preview"
519 <TableCell>Name</TableCell>
520 {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
521 <TableCell>{valueLabel}</TableCell>
522 <TableCell>Collection</TableCell>
526 {data.map((param: ProcessIOParameter) => {
527 const firstVal = param.value.length > 0 ? param.value[0] : undefined;
528 const rest = param.value.slice(1);
529 const mainRowClasses = {
530 [classes.noBorderRow]: rest.length > 0,
534 <React.Fragment key={param.id}>
536 className={classNames(mainRowClasses)}
537 data-cy="process-io-param"
539 <TableCell>{param.id}</TableCell>
540 {showLabel && <TableCell>{param.label}</TableCell>}
545 showImagePreview={showImagePreview}
549 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
550 <Typography className={classes.paramValue}>{firstVal?.collection}</Typography>
553 {rest.map((val, i) => {
555 [classes.noBorderRow]: i < rest.length - 1,
556 [classes.secondaryRow]: val.secondary,
560 className={classNames(rowClasses)}
564 {showLabel && <TableCell />}
568 showImagePreview={showImagePreview}
571 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
572 <Typography className={classes.paramValue}>{val.collection}</Typography>
586 interface ProcessValuePreviewProps {
587 value: ProcessIOValue;
588 showImagePreview: boolean;
591 const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
592 <Typography className={classes.paramValue}>
593 {value.imageUrl && showImagePreview ? (
595 className={classes.imagePreview}
602 {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
603 <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>{value.display}</span>
607 interface ProcessIORawDataProps {
608 data: ProcessIOParameter[];
611 const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
612 <Paper elevation={0}>
614 lines={[JSON.stringify(data, null, 2)]}
620 interface ProcessInputMountsDataProps {
621 mounts: InputCollectionMount[];
624 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
626 const ProcessInputMounts = withStyles(styles)(
627 connect((state: RootState) => ({
629 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
631 className={classes.tableRoot}
632 aria-label="Process Input Mounts"
636 <TableCell>Path</TableCell>
637 <TableCell>Portable Data Hash</TableCell>
641 {mounts.map(mount => (
642 <TableRow key={mount.path}>
644 <pre>{mount.path}</pre>
648 to={getNavUrl(mount.pdh, auth)}
649 className={classes.keepLink}
661 type FileWithSecondaryFiles = {
662 secondaryFiles: File[];
665 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
667 case isPrimitiveOfType(input, CWLType.BOOLEAN):
668 const boolValue = (input as BooleanCommandInputParameter).value;
669 return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
670 ? [{ display: renderPrimitiveValue(boolValue, false) }]
671 : [{ display: <EmptyValue /> }];
673 case isPrimitiveOfType(input, CWLType.INT):
674 case isPrimitiveOfType(input, CWLType.LONG):
675 const intValue = (input as IntCommandInputParameter).value;
676 return intValue !== undefined &&
677 // Missing values are empty array
678 !(Array.isArray(intValue) && intValue.length === 0)
679 ? [{ display: renderPrimitiveValue(intValue, false) }]
680 : [{ display: <EmptyValue /> }];
682 case isPrimitiveOfType(input, CWLType.FLOAT):
683 case isPrimitiveOfType(input, CWLType.DOUBLE):
684 const floatValue = (input as FloatCommandInputParameter).value;
685 return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
686 ? [{ display: renderPrimitiveValue(floatValue, false) }]
687 : [{ display: <EmptyValue /> }];
689 case isPrimitiveOfType(input, CWLType.STRING):
690 const stringValue = (input as StringCommandInputParameter).value || undefined;
691 return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
692 ? [{ display: renderPrimitiveValue(stringValue, false) }]
693 : [{ display: <EmptyValue /> }];
695 case isPrimitiveOfType(input, CWLType.FILE):
696 const mainFile = (input as FileCommandInputParameter).value;
697 // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
698 const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
699 const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
700 const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
702 ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
703 : [{ display: <EmptyValue /> }];
705 case isPrimitiveOfType(input, CWLType.DIRECTORY):
706 const directory = (input as DirectoryCommandInputParameter).value;
707 return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
708 ? [directoryToProcessIOValue(directory, auth, pdh)]
709 : [{ display: <EmptyValue /> }];
711 case getEnumType(input) !== null:
712 const enumValue = (input as EnumCommandInputParameter).value;
713 return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
715 case isArrayOfType(input, CWLType.STRING):
716 const strArray = (input as StringArrayCommandInputParameter).value || [];
717 return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
719 case isArrayOfType(input, CWLType.INT):
720 case isArrayOfType(input, CWLType.LONG):
721 const intArray = (input as IntArrayCommandInputParameter).value || [];
722 return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
724 case isArrayOfType(input, CWLType.FLOAT):
725 case isArrayOfType(input, CWLType.DOUBLE):
726 const floatArray = (input as FloatArrayCommandInputParameter).value || [];
727 return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
729 case isArrayOfType(input, CWLType.FILE):
730 const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
731 const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
733 // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
734 let fileArrayValues: ProcessIOValue[] = [];
735 for (let i = 0; i < fileArrayMainFiles.length; i++) {
736 const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
737 fileArrayValues.push(
738 // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
739 ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
740 ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
744 return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
746 case isArrayOfType(input, CWLType.DIRECTORY):
747 const directories = (input as DirectoryArrayCommandInputParameter).value || [];
748 return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
751 return [{ display: <UnsupportedValue /> }];
755 const renderPrimitiveValue = (value: any, asChip: boolean) => {
756 const isObject = typeof value === "object";
761 label={String(value)}
764 <pre key={value}>{String(value)}</pre>
767 return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
772 * @returns keep url without keep: prefix
774 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
775 const isKeepUrl = file.location?.startsWith("keep:") || false;
776 const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
777 return keepUrl || "";
780 interface KeepUrlProps {
782 res: File | Directory;
786 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
787 const keepUrl = getKeepUrl(res, pdh);
788 return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
791 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
792 const pdhUrl = getResourcePdhUrl(res, pdh);
793 // Passing a pdh always returns a relative wb2 collection url
794 const pdhWbPath = getNavUrl(pdhUrl, auth);
795 return pdhUrl && pdhWbPath ? (
796 <Tooltip title={"View collection in Workbench"}>
799 className={classes.keepLink}
809 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
810 const keepUrl = getKeepUrl(res, pdh);
811 const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
812 const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
814 const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
815 return keepUrlPathNav ? (
816 <Tooltip title={"View in keep-web"}>
818 className={classes.keepLink}
819 href={keepUrlPathNav}
821 rel="noopener noreferrer"
831 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
832 let keepUrl = getKeepUrl(file, pdh);
833 return getInlineFileUrl(
834 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
835 auth.config.keepWebServiceUrl,
836 auth.config.keepWebInlineServiceUrl
840 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
841 const keepUrl = getKeepUrl(file, pdh);
842 return getInlineFileUrl(
843 `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
844 auth.config.keepWebServiceUrl,
845 auth.config.keepWebInlineServiceUrl
849 const isFileImage = (basename?: string): boolean => {
850 return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
853 const isFileUrl = (location?: string): boolean =>
854 !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
856 const normalizeDirectoryLocation = (directory: Directory): Directory => {
857 if (!directory.location) {
862 location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
866 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
867 if (isExternalValue(directory)) {
868 return { display: <UnsupportedValue /> };
871 const normalizedDirectory = normalizeDirectoryLocation(directory);
876 res={normalizedDirectory}
883 res={normalizedDirectory}
890 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
891 if (isExternalValue(file)) {
892 return { display: <UnsupportedValue /> };
895 if (isFileUrl(file.location)) {
909 const resourcePdh = getResourcePdhUrl(file, pdh);
919 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
921 resourcePdh !== mainFilePdh ? (
933 const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
935 export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
937 const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
939 const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
942 label={"Cannot display value"}
946 const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
947 <span className={classes.imagePlaceholder}>