// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 import React, { ReactElement, memo, useState } from "react"; import { Dispatch } from "redux"; import { StyleRulesCallback, WithStyles, withStyles, Card, CardHeader, IconButton, CardContent, Tooltip, Typography, Tabs, Tab, Table, TableHead, TableBody, TableRow, TableCell, Paper, Grid, Chip, CircularProgress, } from "@material-ui/core"; import { ArvadosTheme } from "common/custom-theme"; import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon"; import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view"; import { BooleanCommandInputParameter, CommandInputParameter, CWLType, Directory, DirectoryArrayCommandInputParameter, DirectoryCommandInputParameter, EnumCommandInputParameter, FileArrayCommandInputParameter, FileCommandInputParameter, FloatArrayCommandInputParameter, FloatCommandInputParameter, IntArrayCommandInputParameter, IntCommandInputParameter, isArrayOfType, isPrimitiveOfType, StringArrayCommandInputParameter, StringCommandInputParameter, getEnumType, } from "models/workflow"; import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter"; import { File } from "models/workflow"; import { getInlineFileUrl } from "views-components/context-menu/actions/helpers"; import { AuthState } from "store/auth/auth-reducer"; import mime from "mime"; import { DefaultView } from "components/default-view/default-view"; import { getNavUrl } from "routes/routes"; import { Link as RouterLink } from "react-router-dom"; import { Link as MuiLink } from "@material-ui/core"; import { InputCollectionMount } from "store/processes/processes-actions"; import { connect } from "react-redux"; import { RootState } from "store/store"; import { ProcessOutputCollectionFiles } from "./process-output-collection-files"; import { Process } from "store/processes/process"; import { navigateTo } from "store/navigation/navigation-action"; import classNames from "classnames"; import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet"; import { KEEP_URL_REGEX } from "models/resource"; type CssRules = | "card" | "content" | "title" | "header" | "avatar" | "iconHeader" | "tableWrapper" | "tableRoot" | "paramValue" | "keepLink" | "collectionLink" | "imagePreview" | "valArray" | "secondaryVal" | "secondaryRow" | "emptyValue" | "noBorderRow" | "symmetricTabs" | "imagePlaceholder" | "rowWithPreview" | "labelColumn"; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ card: { height: "100%", }, header: { paddingTop: theme.spacing.unit, paddingBottom: 0, }, iconHeader: { fontSize: "1.875rem", color: theme.customs.colors.greyL, }, avatar: { alignSelf: "flex-start", paddingTop: theme.spacing.unit * 0.5, }, content: { height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`, padding: theme.spacing.unit * 1.0, paddingTop: 0, "&:last-child": { paddingBottom: theme.spacing.unit * 1, }, }, title: { overflow: "hidden", paddingTop: theme.spacing.unit * 0.5, color: theme.customs.colors.greyD, fontSize: "1.875rem", }, tableWrapper: { height: "auto", maxHeight: `calc(100% - ${theme.spacing.unit * 4.5}px)`, overflow: "auto", }, tableRoot: { width: "100%", "& thead th": { verticalAlign: "bottom", paddingBottom: "10px", }, "& td, & th": { paddingRight: "25px", }, }, paramValue: { display: "flex", alignItems: "flex-start", flexDirection: "column", }, keepLink: { color: theme.palette.primary.main, textDecoration: "none", overflowWrap: "break-word", cursor: "pointer", }, collectionLink: { margin: "10px", "& a": { color: theme.palette.primary.main, textDecoration: "none", overflowWrap: "break-word", cursor: "pointer", }, }, imagePreview: { maxHeight: "15em", maxWidth: "15em", marginBottom: theme.spacing.unit, }, valArray: { display: "flex", gap: "10px", flexWrap: "wrap", "& span": { display: "inline", }, }, secondaryVal: { paddingLeft: "20px", }, secondaryRow: { height: "29px", verticalAlign: "top", position: "relative", top: "-9px", }, emptyValue: { color: theme.customs.colors.grey700, }, noBorderRow: { "& td": { borderBottom: "none", }, }, symmetricTabs: { "& button": { flexBasis: "0", }, }, imagePlaceholder: { width: "60px", height: "60px", display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: "#cecece", borderRadius: "10px", }, rowWithPreview: { verticalAlign: "bottom", }, labelColumn: { minWidth: "120px", }, }); export enum ProcessIOCardType { INPUT = "Inputs", OUTPUT = "Outputs", } export interface ProcessIOCardDataProps { process?: Process; label: ProcessIOCardType; params: ProcessIOParameter[] | null; raw: any; mounts?: InputCollectionMount[]; outputUuid?: string; showParams?: boolean; } export interface ProcessIOCardActionProps { navigateTo: (uuid: string) => void; } const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({ navigateTo: uuid => dispatch(navigateTo(uuid)), }); type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles & MPVPanelProps; export const ProcessIOCard = withStyles(styles)( connect( null, mapDispatchToProps )( ({ classes, label, params, raw, mounts, outputUuid, doHidePanel, doMaximizePanel, doUnMaximizePanel, panelMaximized, panelName, process, navigateTo, showParams, }: ProcessIOCardProps) => { const [mainProcTabState, setMainProcTabState] = useState(0); const [subProcTabState, setSubProcTabState] = useState(0); const handleMainProcTabChange = (event: React.MouseEvent, value: number) => { setMainProcTabState(value); }; const handleSubProcTabChange = (event: React.MouseEvent, value: number) => { setSubProcTabState(value); }; const [showImagePreview, setShowImagePreview] = useState(false); const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon; const mainProcess = !(process && process!.containerRequest.requestingContainerUuid); const loading = raw === null || raw === undefined || params === null; const hasRaw = !!(raw && Object.keys(raw).length > 0); const hasParams = !!(params && params.length > 0); // Subprocess const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length); const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid); return ( } title={ {label} } action={
{mainProcess && ( { setShowImagePreview(!showImagePreview); }} > {showImagePreview ? : } )} {doUnMaximizePanel && panelMaximized && ( )} {doMaximizePanel && !panelMaximized && ( )} {doHidePanel && ( )}
} /> {mainProcess || showParams ? ( <> {/* raw is undefined until params are loaded */} {loading && ( )} {/* Once loaded, either raw or params may still be empty * Raw when all params are empty * Params when raw is provided by containerRequest properties but workflow mount is absent for preview */} {!loading && (hasRaw || hasParams) && ( <> {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */} {hasParams && } {!showParams && } {mainProcTabState === 0 && params && hasParams && (
)} {(mainProcTabState === 1 || !hasParams) && (
)} )} {!loading && !hasRaw && !hasParams && ( )} ) : ( // Subprocess <> {loading && ( )} {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ? ( <> {hasInputMounts && } {hasOutputCollecton && }
{subProcTabState === 0 && hasInputMounts && } {subProcTabState === 0 && hasOutputCollecton && ( <> {outputUuid && ( Output Collection:{" "} { navigateTo(outputUuid || ""); }} > {outputUuid} )} )} {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
)}
) : ( )} )}
); } ) ); export type ProcessIOValue = { display: ReactElement; imageUrl?: string; collection?: ReactElement; secondary?: boolean; }; export type ProcessIOParameter = { id: string; label: string; value: ProcessIOValue[]; }; interface ProcessIOPreviewDataProps { data: ProcessIOParameter[]; showImagePreview: boolean; valueLabel: string; } type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles; const ProcessIOPreview = memo( withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => { const showLabel = data.some((param: ProcessIOParameter) => param.label); return ( Name {showLabel && Label} {valueLabel} Collection {data.map((param: ProcessIOParameter) => { const firstVal = param.value.length > 0 ? param.value[0] : undefined; const rest = param.value.slice(1); const mainRowClasses = { [classes.noBorderRow]: rest.length > 0, }; return ( {param.id} {showLabel && {param.label}} {firstVal && ( )} {firstVal?.collection} {rest.map((val, i) => { const rowClasses = { [classes.noBorderRow]: i < rest.length - 1, [classes.secondaryRow]: val.secondary, }; return ( {showLabel && } {val.collection} ); })} ); })}
); }) ); interface ProcessValuePreviewProps { value: ProcessIOValue; showImagePreview: boolean; } const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles) => ( {value.imageUrl && showImagePreview ? ( Inline Preview ) : ( "" )} {value.imageUrl && !showImagePreview ? : ""} {value.display} )); interface ProcessIORawDataProps { data: ProcessIOParameter[]; } const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => ( )); interface ProcessInputMountsDataProps { mounts: InputCollectionMount[]; } type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles; const ProcessInputMounts = withStyles(styles)( connect((state: RootState) => ({ auth: state.auth, }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => ( Path Portable Data Hash {mounts.map(mount => (
{mount.path}
{mount.pdh}
))}
)) ); type FileWithSecondaryFiles = { secondaryFiles: File[]; }; export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => { switch (true) { case isPrimitiveOfType(input, CWLType.BOOLEAN): const boolValue = (input as BooleanCommandInputParameter).value; return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0) ? [{ display: renderPrimitiveValue(boolValue, false) }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.INT): case isPrimitiveOfType(input, CWLType.LONG): const intValue = (input as IntCommandInputParameter).value; return intValue !== undefined && // Missing values are empty array !(Array.isArray(intValue) && intValue.length === 0) ? [{ display: renderPrimitiveValue(intValue, false) }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.FLOAT): case isPrimitiveOfType(input, CWLType.DOUBLE): const floatValue = (input as FloatCommandInputParameter).value; return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0) ? [{ display: renderPrimitiveValue(floatValue, false) }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.STRING): const stringValue = (input as StringCommandInputParameter).value || undefined; return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0) ? [{ display: renderPrimitiveValue(stringValue, false) }] : [{ display: }]; case isPrimitiveOfType(input, CWLType.FILE): const mainFile = (input as FileCommandInputParameter).value; // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || []; const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles]; const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : ""; return files.length ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : "")) : [{ display: }]; case isPrimitiveOfType(input, CWLType.DIRECTORY): const directory = (input as DirectoryCommandInputParameter).value; return directory !== undefined && !(Array.isArray(directory) && directory.length === 0) ? [directoryToProcessIOValue(directory, auth, pdh)] : [{ display: }]; case getEnumType(input) !== null: const enumValue = (input as EnumCommandInputParameter).value; return enumValue !== undefined && enumValue ? [{ display:
{enumValue}
}] : [{ display: }]; case isArrayOfType(input, CWLType.STRING): const strArray = (input as StringArrayCommandInputParameter).value || []; return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; case isArrayOfType(input, CWLType.INT): case isArrayOfType(input, CWLType.LONG): const intArray = (input as IntArrayCommandInputParameter).value || []; return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; case isArrayOfType(input, CWLType.FLOAT): case isArrayOfType(input, CWLType.DOUBLE): const floatArray = (input as FloatArrayCommandInputParameter).value || []; return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))} }] : [{ display: }]; case isArrayOfType(input, CWLType.FILE): const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || []; const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : ""; // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering let fileArrayValues: ProcessIOValue[] = []; for (let i = 0; i < fileArrayMainFiles.length; i++) { const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || []; fileArrayValues.push( // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []), ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh)) ); } return fileArrayValues.length ? fileArrayValues : [{ display: }]; case isArrayOfType(input, CWLType.DIRECTORY): const directories = (input as DirectoryArrayCommandInputParameter).value || []; return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: }]; default: return [{ display: }]; } }; const renderPrimitiveValue = (value: any, asChip: boolean) => { const isObject = typeof value === "object"; if (!isObject) { return asChip ? ( ) : (
{String(value)}
); } else { return asChip ? : ; } }; /* * @returns keep url without keep: prefix */ const getKeepUrl = (file: File | Directory, pdh?: string): string => { const isKeepUrl = file.location?.startsWith("keep:") || false; const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location; return keepUrl || ""; }; interface KeepUrlProps { auth: AuthState; res: File | Directory; pdh?: string; } const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => { const keepUrl = getKeepUrl(res, pdh); return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : ""; }; const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles) => { const pdhUrl = getResourcePdhUrl(res, pdh); // Passing a pdh always returns a relative wb2 collection url const pdhWbPath = getNavUrl(pdhUrl, auth); return pdhUrl && pdhWbPath ? ( {pdhUrl} ) : ( <> ); }); const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles) => { const keepUrl = getKeepUrl(res, pdh); const keepUrlParts = keepUrl ? keepUrl.split("/") : []; const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : ""; const keepUrlPathNav = getKeepNavUrl(auth, res, pdh); return keepUrlPathNav ? ( {keepUrlPath || "/"} ) : ( ); }); const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => { let keepUrl = getKeepUrl(file, pdh); return getInlineFileUrl( `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl ); }; const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => { const keepUrl = getKeepUrl(file, pdh); return getInlineFileUrl( `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl ); }; const isFileImage = (basename?: string): boolean => { return basename ? (mime.getType(basename) || "").startsWith("image/") : false; }; const isFileUrl = (location?: string): boolean => !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://")); const normalizeDirectoryLocation = (directory: Directory): Directory => { if (!directory.location) { return directory; } return { ...directory, location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/", }; }; const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => { if (isExternalValue(directory)) { return { display: }; } const normalizedDirectory = normalizeDirectoryLocation(directory); return { display: ( ), collection: ( ), }; }; const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => { if (isExternalValue(file)) { return { display: }; } if (isFileUrl(file.location)) { return { display: ( {file.location} ), secondary, }; } const resourcePdh = getResourcePdhUrl(file, pdh); return { display: ( ), secondary, imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined, collection: resourcePdh !== mainFilePdh ? ( ) : ( <> ), }; }; const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include"); export const EmptyValue = withStyles(styles)(({ classes }: WithStyles) => No value); const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles) => Cannot display value); const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles) => ( } label={"Cannot display value"} /> )); const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles) => ( ));