diff --git a/gui/src/components/north/NorthTool.js b/gui/src/components/north/NorthTool.js index 8e2c8ff5a697a49888232d3fc8609ad618f414e4..8ea9ceb491c0366060067fd3293c68f77efc8dea 100644 --- a/gui/src/components/north/NorthTool.js +++ b/gui/src/components/north/NorthTool.js @@ -51,6 +51,53 @@ const LaunchButton = React.memo(function LaunchButton(props) { return <Button color="primary" variant="contained" size="small" {...props} /> }) +export function useNorthToolHook(tool, uploadId, path) { + const { name, path_prefix, with_path } = tool + const { api } = useApi() + const { raiseError } = useErrors() + + const getToolStatus = useCallback(() => { + return api + .get(`north/${name}?upload_id=${uploadId}`) + .then(response => ({state: response.data.state, uploadid_is_mounted: response.upload_id_is_mounted})) + .catch(error => { + raiseError(error) + return { state: "error", uploadid_is_mounted: null } + }) + }, [api, raiseError, name, uploadId]) + + const launch = useCallback(() => { + return api.post(`north/${name}?upload_id=${uploadId}`) + .then(response => { + let toolUrl = `${northBase}/user/${response.username}/${response.tool}` + + if (with_path && response.upload_mount_dir && path) { + if (path_prefix) { + toolUrl = `${toolUrl}/${path_prefix}` + } + toolUrl = `${toolUrl}/${response.upload_mount_dir}/${path}` + } + + return { toolUrl, state: response.data.state, uploadid_is_mounted: response.upload_id_is_mounted } + }) + .catch(error => { + raiseError(error) + return { toolUrl: null, state: "stopped", uploadid_is_mounted: null } + }) + }, [api, raiseError, name, uploadId, path, path_prefix, with_path]) + + const stop = useCallback(() => { + return api.delete(`north/${name}`) + .then(() => "stopped") + .catch(error => { + raiseError(error) + return "error" + }) + }, [api, raiseError, name]) + + return { launch, stop, getToolStatus } +} + export const NorthToolButtons = React.memo(function NorthToolButton() { const {name, launch, stop, state} = useNorthTool() return ( @@ -90,68 +137,37 @@ const useStyles = makeStyles(theme => ({ })) const NorthTool = React.memo(function NorthTool({tool, uploadId, path, children}) { - const {name, title, version, description, short_description, icon, path_prefix, with_path} = tool const styles = useStyles() - const {api} = useApi() - const {raiseError} = useErrors() - - const [state, setState] = useState('stopped') - - const getToolStatus = useCallback(() => { - return api.get(`north/${name}`) - .then(response => { - return response.data.state - }).catch(raiseError) - }, [api, raiseError, name]) + const [toolState, setToolState] = useState("stopped") + const { launch, stop, getToolStatus } = useNorthToolHook(tool, uploadId, path) useEffect(() => { - const toolStatus = getToolStatus() - if (toolStatus) { - toolStatus.then((toolStatus) => { - setState(toolStatus) - }) + async function fetchStatus() { + const {state: status} = await getToolStatus() + setToolState(status) } - }, [setState, getToolStatus]) - - const launch = useCallback(() => { - // We get the current actual tools status and do not use the one used to display the status! - setState('starting') - api.post(`north/${name}?upload_id=${uploadId}`) - .then((response) => { - console.log(response) - let toolUrl = `${northBase}/user/${response.username}/${response.tool}` - if (with_path && response.upload_mount_dir && path) { - if (path_prefix) { - toolUrl = `${toolUrl}/${path_prefix}` - } - toolUrl = `${toolUrl}/${response.upload_mount_dir}/${path}` - } - console.log(toolUrl) - window.open(toolUrl, name) - setState(response.data.state) - }) - .catch(errors => { - raiseError(errors) - setState('stopped') - }) - }, [setState, api, raiseError, name, with_path, uploadId, path, path_prefix]) - - const stop = useCallback(() => { - setState('stopping') - api.delete(`north/${name}`) - .then((response) => { - console.log(response) - setState('stopped') - }) - .catch(raiseError) - }, [api, raiseError, setState, name]) + fetchStatus() + }, [getToolStatus]) + + const handleLaunch = useCallback(async () => { + setToolState("starting") + const { toolUrl, state } = await launch() + setToolState(state) + if (toolUrl) window.open(toolUrl, tool.name) + }, [launch, tool.name]) + + const handleStop = useCallback(async () => { + setToolState("stopping") + const updatedState = await stop() + setToolState(updatedState) + }, [stop]) const value = useMemo(() => ({ - state: state, - launch: launch, - stop: stop, + state: toolState, + launch: handleLaunch, + stop: handleStop, ...tool - }), [tool, state, launch, stop]) + }), [tool, toolState, handleLaunch, handleStop]) if (children) { return ( @@ -165,15 +181,17 @@ const NorthTool = React.memo(function NorthTool({tool, uploadId, path, children} <northToolContext.Provider value={value}> <Box marginY={1}> <Box display="flex" flexDirection="row" marginBottom={1}> - {icon ? ( + {tool.icon ? ( <Icon classes={{root: styles.iconRoot}}> - <img className={styles.imageIcon} src={`${process.env.PUBLIC_URL}/${icon}`} alt="icon"/> + <img className={styles.imageIcon} src={`${process.env.PUBLIC_URL}/${tool.icon}`} alt="icon"/> </Icon> ) : ( <AssessmentIcon classes={{root: styles.iconRoot}}/> )} <Box flexGrow={1}> - <Typography><b>{title || name}{version && <span> ({version})</span>}</b>: {short_description || description}</Typography> + <Typography> + <b>{tool.title || tool.name}{tool.version && <span> ({tool.version})</span>}</b>: {tool.short_description || tool.description} + </Typography> </Box> </Box> <NorthToolButtons /> diff --git a/gui/src/components/search/SearchResults.js b/gui/src/components/search/SearchResults.js index 2c953355d8a6e2eb14b34867b79ea1ef9c7b2528..4bae7ed710d59369f77a9281b25428838fd79f1f 100644 --- a/gui/src/components/search/SearchResults.js +++ b/gui/src/components/search/SearchResults.js @@ -18,7 +18,7 @@ import React, { useState, useMemo, useEffect, useCallback } from 'react' import PropTypes from 'prop-types' import jmespath from 'jmespath' -import { Paper, Typography, Tooltip, IconButton } from '@material-ui/core' +import {Paper, Typography, Tooltip, IconButton, Button, Dialog, DialogTitle, DialogContent} from '@material-ui/core' import { Alert } from '@material-ui/lab' import Icon from '@material-ui/core/Icon' import GitHubIcon from '@material-ui/icons/GitHub' @@ -35,15 +35,36 @@ import { MaterialRowActions } from '../material/MaterialDetails' import { pluralize, formatInteger, parseJMESPath } from '../../utils' import { isEmpty, isArray } from 'lodash' import { useSearchContext } from './SearchContext' +import {useTools} from "../north/NorthPage" +import DialogActions from "@material-ui/core/DialogActions" +import {useNorthToolHook} from "../north/NorthTool" +import {useKeycloak} from "@react-keycloak/web" +import {useErrors} from "../errors" +import {northBase} from "../../config" + +/** + * Helper function to resolve a jmespath-like path to a quantity in an entry + */ +export const extractPathContent = ({action, data, key}) => { + const {path} = parseJMESPath(action[key]) + return jmespath.search(data, path) +} /** * Used to retrieve an URL link from the row metadata and display a link icon to * that resource. */ export const ActionURL = React.memo(({action, data}) => { - const {path} = parseJMESPath(action.path) - let href = jmespath.search(data, path) - href = isArray(href) ? href[0] : href + const {raiseError} = useErrors() + const href = extractPathContent({action, data, key: 'path'}) + + if (isArray(href)) { + raiseError(` + Encountered and array in ${action.path}. Expected a string. + Update the path in you app to target only one item in the array.` + ) + return + } const disabled = !href const size = 'medium' const svgIcon = { @@ -62,6 +83,150 @@ ActionURL.propTypes = { data: PropTypes.object.isRequired // ES index data } +/** + * Used to build a north container and open it in a new tab. + */ +export const NorthURL = React.memo(({ action, data }) => { + const {raiseError} = useErrors() + const { keycloak } = useKeycloak() + const isAuthenticated = keycloak.authenticated + + const northTools = useTools() + const [openDialog, setOpenDialog] = useState(false) + const [toolUrl, setToolUrl] = useState(undefined) + const [dialogMessage, setDialogMessage] = useState("") + const [showStopOnly, setShowStopOnly] = useState(false) + + const filepath = extractPathContent({ action, data, key: "filepath" }) + + const toolsData = action?.tool_name + ? Object.keys(northTools) + .filter(tool => tool === action.tool_name) + .map(key => { + const { with_path, mount_path, icon, path_prefix } = northTools[key] + return { + name: key, + title: key, + with_path, + mount_path, + icon, + path_prefix + } + }) + : [] + + const tool = toolsData.length > 0 ? toolsData[0] : null + const { launch, stop, getToolStatus } = useNorthToolHook(tool ?? {}, data?.upload_id ?? "", filepath) + + const handleLaunch = async () => { + if (!tool) return + const {state: currentState, uploadid_is_mounted} = await getToolStatus() + if (currentState === "stopped") { + const { toolUrl } = await launch() + setToolUrl(toolUrl) + if (toolUrl) window.open(toolUrl, tool.name) + } else if (uploadid_is_mounted === false) { + setDialogMessage( + `The upload ${data?.upload_id} is not mounted. You need to stop the tool before relaunching it. + Stopping the tool may cause loss of unsaved data.`) + setShowStopOnly(true) + setOpenDialog(true) + } else { + setToolUrl(toolUrl) + setDialogMessage("The tool is already running. Stopping it will delete any unsaved data.") + setShowStopOnly(false) + setOpenDialog(true) + } + } + + const handleStopAndRetry = async () => { + await handleStop() + setOpenDialog(false) + } + + const handleStop = async () => { + if (!tool) return + await stop() + setOpenDialog(false) + } + + const handleOpenTool = () => { + setOpenDialog(false) + window.open( + toolUrl || `${northBase}/user/${keycloak.tokenParsed?.preferred_username}/${tool.name}`, + "_blank" + ) + } + + const disabled = !filepath || !isAuthenticated + const tooltipMessage = !isAuthenticated + ? "You must be logged in to use this tool." + : !filepath + ? "No file path found. Tool cannot be launched." + : action.description || "" + + if (!tool) { + return null + } + + if (isArray(filepath)) { + raiseError(` + Encountered and array in ${action.filepath}. Expected a string. + Update the path in you app to target only one item in the array.` + ) + return + } + + return ( + <> + <Tooltip title={tooltipMessage}> + <div> + <IconButton + onClick={handleLaunch} + size="medium" + disabled={disabled} + style={{ filter: disabled ? "grayscale(100%)" : "none" }} + > + <Icon fontSize="medium"> + {tool.icon ? ( + <img + style={{ width: "30px", height: "30px", objectFit: "contain" }} + src={`${process.env.PUBLIC_URL}/${tool.icon}`} + alt="icon" + /> + ) : ( + <Icon fontSize="medium">{action?.icon || "launch"}</Icon> + )} + </Icon> + </IconButton> + </div> + </Tooltip> + + <Dialog open={openDialog} onClose={() => setOpenDialog(false)}> + <DialogTitle>Tool Status</DialogTitle> + <DialogContent> + <p>{dialogMessage}</p> + </DialogContent> + <DialogActions> + <Button onClick={handleStopAndRetry} color="error"> + Stop {action.tool_name} + </Button> + {!showStopOnly && ( + <Button onClick={handleOpenTool} color="primary"> + Open {action.tool_name} + </Button> + )} + <Button onClick={() => setOpenDialog(false)}>Cancel</Button> + </DialogActions> + </Dialog> + </> + ) +}) +NorthURL.propTypes = { + action: PropTypes.object.isRequired, + data: PropTypes.object.isRequired +} + /** * Displays the list of search results. */ @@ -101,7 +266,8 @@ export const SearchResults = React.memo(({ const actionComponents = []; (rows?.actions?.items || []).forEach((action, i) => { const component = { - url: <ActionURL key={i} action={action} data={data}/> + url: <ActionURL key={i} action={action} data={data}/>, + north: <NorthURL key={i} action={action} data={data}/> }[action.type] if (component) { actionComponents.push(component) diff --git a/nomad/app/v1/routers/north.py b/nomad/app/v1/routers/north.py index 73c795bcf98baf1a2e78a37a02c94c2ccfe14312..49d108b02ebe81e96c7a7a8a52481a5aa2f520a3 100644 --- a/nomad/app/v1/routers/north.py +++ b/nomad/app/v1/routers/north.py @@ -65,6 +65,7 @@ class ToolResponseModel(BaseModel): username: str upload_mount_dir: str | None = None data: ToolModel + upload_id_is_mounted: bool | None = None class ToolsResponseModel(BaseModel): @@ -148,10 +149,42 @@ async def tool(name: str) -> ToolModel: async def get_tool( tool: ToolModel = Depends(tool), user: User = Depends(create_user_dependency(required=True)), + upload_id: str | None = None, ): - return ToolResponseModel( - tool=tool.name, username=user.username, data=_get_status(tool, user) - ) + if upload_id: + url = f'{config.hub_url()}/api/users/{user.username}' + response = requests.get(url, headers=hub_api_headers) + return ToolResponseModel( + tool=tool.name, + username=user.username, + data=_get_status(tool, user), + upload_id_is_mounted=_check_uploadid_is_mounted( + tool, response.json(), upload_id + ), + ) + else: + return ToolResponseModel( + tool=tool.name, username=user.username, data=_get_status(tool, user) + ) + + +def _check_uploadid_is_mounted( + tool: ToolModel, response: dict, upload_id: str +) -> bool | None: + try: + servers = response.get('servers', {}) + tool_data = servers.get(tool.name, {}) + if not tool_data: + return None + user_options = tool_data.get('user_options', {}) + uploads = user_options.get('uploads', []) + for upload in uploads: + host_path = upload.get('host_path', '') + if upload_id in host_path: + return True + return False + except Exception: + return None @router.post( @@ -240,12 +273,23 @@ async def start_tool( # Check if the tool/named server already exists _get_status(tool, user) if tool.state != ToolStateEnum.stopped: - return ToolResponseModel( - tool=tool.name, - username=user.username, - data=_get_status(tool, user), - upload_mount_dir=upload_mount_dir, - ) + if upload_id and response.json().get('servers', None): + return ToolResponseModel( + tool=tool.name, + username=user.username, + data=_get_status(tool, user), + upload_mount_dir=upload_mount_dir, + upload_id_is_mounted=_check_uploadid_is_mounted( + tool, response.json(), upload_id + ), + ) + else: + return ToolResponseModel( + tool=tool.name, + username=user.username, + data=_get_status(tool, user), + upload_mount_dir=upload_mount_dir, + ) url = f'{config.hub_url()}/api/users/{user.username}/servers/{tool.name}' access_token = generate_simple_token( diff --git a/nomad/config/models/ui.py b/nomad/config/models/ui.py index ed171ebd81925128e2e02dcf960a5d2483f900f3..10f8395a9d9ae49820da0570a537dd4361407d29 100644 --- a/nomad/config/models/ui.py +++ b/nomad/config/models/ui.py @@ -374,14 +374,43 @@ class RowActionURL(RowAction): return values +class RowActionNorth(RowAction): + """Action that will open a NORTH tool given its name in the archive.""" + + filepath: str = Field( + description="""JMESPath pointing to a path in the archive that contains the filepath.""" + ) + tool_name: str = Field(description="""Name of the NORTH tool to open.""") + type: Literal['north'] = Field( + 'north', description='Set as `north` to get this widget type.' + ) + + @model_validator(mode='before') + @classmethod + def _validate(cls, values): + if isinstance(values, BaseModel): + values = values.model_dump(exclude_none=True) + values['type'] = 'north' + return values + + +RowActionsItemType = Annotated[ + Union[ + RowActionNorth, + RowActionURL, + ], + Field(discriminator='type'), +] + + class RowActions(Options): """Controls the visualization of row actions that are shown at the end of each row.""" enabled: bool = Field(True, description='Whether to enable row actions.') - options: dict[str, RowActionURL] | None = Field( + options: dict[str, RowActionsItemType] | None = Field( None, deprecated="""Deprecated, use 'items' instead.""" ) - items: list[RowActionURL] | None = Field( + items: list[RowActionsItemType] | None = Field( None, description='List of actions to show for each row.' )