diff --git a/client/galaxy/scripts/bundleEntries.js b/client/galaxy/scripts/bundleEntries.js index abc8be4d625..c666d664310 100644 --- a/client/galaxy/scripts/bundleEntries.js +++ b/client/galaxy/scripts/bundleEntries.js @@ -100,6 +100,8 @@ export const chartUtilities = { export { initMasthead } from "components/Masthead/initMasthead"; export { panelManagement } from "onload/globalInits/panelManagement"; export { mountMakoTags } from "components/Tags"; +export { mountJobMetrics } from "components/JobMetrics"; +export { mountJobParameters } from "components/JobParameters"; // Used in common.mako export { default as store } from "storemodern"; diff --git a/client/galaxy/scripts/components/JobMetrics/JobMetrics.vue b/client/galaxy/scripts/components/JobMetrics/JobMetrics.vue new file mode 100644 index 00000000000..67fcd9c829e --- /dev/null +++ b/client/galaxy/scripts/components/JobMetrics/JobMetrics.vue @@ -0,0 +1,80 @@ + + + diff --git a/client/galaxy/scripts/components/JobMetrics/index.js b/client/galaxy/scripts/components/JobMetrics/index.js new file mode 100644 index 00000000000..416a80e8339 --- /dev/null +++ b/client/galaxy/scripts/components/JobMetrics/index.js @@ -0,0 +1,4 @@ +export { default as JobMetrics } from "./JobMetrics"; + +// functions for mounting job metrics in non-Vue environments +export { mountJobMetrics } from "./mount"; diff --git a/client/galaxy/scripts/components/JobMetrics/mount.js b/client/galaxy/scripts/components/JobMetrics/mount.js new file mode 100644 index 00000000000..52206d987fe --- /dev/null +++ b/client/galaxy/scripts/components/JobMetrics/mount.js @@ -0,0 +1,19 @@ +/** + * Endpoint for mounting job metrics from non-Vue environment. + */ +import $ from "jquery"; +import Vue from "vue"; +import JobMetrics from "./JobMetrics.vue"; + +export const mountJobMetrics = (propsData = {}) => { + $(".job-metrics").each((index, el) => { + const jobId = $(el).attr("job_id"); + const datasetId = $(el).attr("dataset_id"); + const datasetType = $(el).attr("dataset_type") || "hda"; + const component = Vue.extend(JobMetrics); + propsData.jobId = jobId; + propsData.datasetId = datasetId; + propsData.datasetType = datasetType; + return new component({ propsData: propsData }).$mount(el); + }); +}; diff --git a/client/galaxy/scripts/components/JobParameters/JobParameters.vue b/client/galaxy/scripts/components/JobParameters/JobParameters.vue new file mode 100644 index 00000000000..fc9a94b6f0d --- /dev/null +++ b/client/galaxy/scripts/components/JobParameters/JobParameters.vue @@ -0,0 +1,122 @@ + + + + diff --git a/client/galaxy/scripts/components/JobParameters/index.js b/client/galaxy/scripts/components/JobParameters/index.js new file mode 100644 index 00000000000..8d76729387f --- /dev/null +++ b/client/galaxy/scripts/components/JobParameters/index.js @@ -0,0 +1,4 @@ +export { default as JobParameters } from "./JobParameters"; + +// functions for mounting job parameters display in non-Vue environments +export { mountJobParameters } from "./mount"; diff --git a/client/galaxy/scripts/components/JobParameters/mount.js b/client/galaxy/scripts/components/JobParameters/mount.js new file mode 100644 index 00000000000..4e7e9f67fb6 --- /dev/null +++ b/client/galaxy/scripts/components/JobParameters/mount.js @@ -0,0 +1,19 @@ +/** + * Endpoint for mounting job parameters from non-Vue environment. + */ +import $ from "jquery"; +import Vue from "vue"; +import JobParameters from "./JobParameters.vue"; + +export const mountJobParameters = (propsData = {}) => { + $(".job-parameters").each((index, el) => { + const jobId = $(el).attr("job_id"); + const datasetId = $(el).attr("dataset_id"); + const datasetType = $(el).attr("dataset_type") || "hda"; + const component = Vue.extend(JobParameters); + propsData.jobId = jobId; + propsData.datasetId = datasetId; + propsData.datasetType = datasetType; + return new component({ propsData: propsData }).$mount(el); + }); +}; diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 8941171de7a..3940e20c4eb 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -8,8 +8,13 @@ from sqlalchemy.orm import aliased from sqlalchemy.sql import select from galaxy import model -from galaxy.exceptions import RequestParameterInvalidException +from galaxy.exceptions import ( + ItemAccessibilityException, + ObjectNotFound, + RequestParameterInvalidException, +) from galaxy.managers.collections import DatasetCollectionManager +from galaxy.managers.datasets import DatasetManager from galaxy.managers.hdas import HDAManager from galaxy.managers.lddas import LDDAManager from galaxy.util import ( @@ -39,6 +44,27 @@ def get_path_key(path_tuple): return path_key +class JobManager(object): + + def __init__(self, app): + self.app = app + self.dataset_manager = DatasetManager(app) + + def get_accessible_job(self, trans, decoded_job_id): + job = trans.sa_session.query(trans.app.model.Job).filter(trans.app.model.Job.id == decoded_job_id).first() + if job is None: + raise ObjectNotFound() + belongs_to_user = (job.user == trans.user) if job.user else (job.session_id == trans.get_galaxy_session().id) + if not trans.user_is_admin and not belongs_to_user: + # Check access granted via output datasets. + if not job.output_datasets: + raise ItemAccessibilityException("Job has no output datasets.") + for data_assoc in job.output_datasets: + if not self.dataset_manager.is_accessible(data_assoc.dataset.dataset, trans.user): + raise ItemAccessibilityException("You are not allowed to rerun this job.") + return job + + class JobSearch(object): """Search for jobs using tool inputs or other jobs""" def __init__(self, app): diff --git a/lib/galaxy/webapps/galaxy/api/jobs.py b/lib/galaxy/webapps/galaxy/api/jobs.py index f42c83a998b..1f54f4e7aea 100644 --- a/lib/galaxy/webapps/galaxy/api/jobs.py +++ b/lib/galaxy/webapps/galaxy/api/jobs.py @@ -12,24 +12,25 @@ from sqlalchemy import or_ from galaxy import exceptions from galaxy import model from galaxy import util -from galaxy.managers.datasets import DatasetManager -from galaxy.managers.jobs import JobSearch +from galaxy.managers.jobs import JobManager, JobSearch from galaxy.web import ( expose_api, expose_api_anonymous, ) -from galaxy.web.base.controller import BaseAPIController -from galaxy.web.base.controller import UsesLibraryMixinItems +from galaxy.web.base.controller import ( + BaseAPIController, + UsesVisualizationMixin +) from galaxy.work.context import WorkRequestContext log = logging.getLogger(__name__) -class JobController(BaseAPIController, UsesLibraryMixinItems): +class JobController(BaseAPIController, UsesVisualizationMixin): def __init__(self, app): super(JobController, self).__init__(app) - self.dataset_manager = DatasetManager(app) + self.job_manager = JobManager(app) self.job_search = JobSearch(app) @expose_api @@ -166,7 +167,7 @@ class JobController(BaseAPIController, UsesLibraryMixinItems): raw_value=str(metric_value), ) - job_dict['job_metrics'] = [metric_to_dict(metric) for metric in job.metrics] + job_dict['job_metrics'] = self._metrics_as_dict(trans, job) return job_dict @expose_api @@ -265,6 +266,163 @@ class JobController(BaseAPIController, UsesLibraryMixinItems): exceptions.RequestParameterInvalidException("Job with id '%s' is not paused" % (job.tool_id)) return self.__dictify_associations(trans, job.output_datasets, job.output_library_datasets) + @expose_api_anonymous + def metrics(self, trans, **kwd): + """ + * GET /api/jobs/{job_id}/metrics + * GET /api/datasets/{dataset_id}/metrics + Return job metrics for specified job. Job accessibility checks are slightly + different than dataset checks, so both methods are available. + + :type job_id: string + :param job_id: Encoded job id + + :type dataset_id: string + :param dataset_id: Encoded HDA or LDDA id + + :type hda_ldda: string + :param hda_ldda: hda if dataset_id is an HDA id (default), ldda if + it is an ldda id. + + :rtype: list + :returns: list containing job metrics + """ + job = self.__get_job(trans, **kwd) + if not trans.user_is_admin and not trans.app.config.expose_potentially_sensitive_job_metrics: + return [] + + return self._metrics_as_dict(trans, job) + + def _metrics_as_dict(self, trans, job): + + def metric_to_dict(metric): + metric_name = metric.metric_name + metric_value = metric.metric_value + metric_plugin = metric.plugin + title, value = trans.app.job_metrics.format(metric_plugin, metric_name, metric_value) + return dict( + title=title, + value=value, + plugin=metric_plugin, + name=metric_name, + raw_value=str(metric_value), + ) + + metrics = [m for m in job.metrics if m.plugin != 'env' or trans.user_is_admin] + return list(map(metric_to_dict, metrics)) + + @expose_api_anonymous + def parameters_display(self, trans, **kwd): + """ + * GET /api/jobs/{job_id}/parameters_display + * GET /api/datasets/{dataset_id}/parameters_display + + Resolve parameters as a list for nested display. More client logic + here than is ideal but it is hard to reason about tool parameter + types on the client relative to the server. Job accessibility checks + are slightly different than dataset checks, so both methods are + available. + + This API endpoint is unstable and tied heavily to Galaxy's JS client code, + this endpoint will change frequently. + + :type job_id: string + :param job_id: Encoded job id + + :type dataset_id: string + :param dataset_id: Encoded HDA or LDDA id + + :type hda_ldda: string + :param hda_ldda: hda if dataset_id is an HDA id (default), ldda if + it is an ldda id. + + :rtype: list + :returns: job parameters for for display + """ + job = self.__get_job(trans, **kwd) + + def inputs_recursive(input_params, param_values, depth=1, upgrade_messages=None): + if upgrade_messages is None: + upgrade_messages = {} + + rval = [] + + for input_index, input in enumerate(input_params.values()): + if input.name in param_values: + if input.type == "repeat": + for i in range(len(param_values[input.name])): + rval.extend(inputs_recursive(input.inputs, param_values[input.name][i], depth=depth + 1)) + elif input.type == "section": + # Get the value of the current Section parameter + rval.append(dict(text=input.name, depth=depth)) + rval.extend(inputs_recursive(input.inputs, param_values[input.name], depth=depth + 1, upgrade_messages=upgrade_messages.get(input.name))) + elif input.type == "conditional": + try: + current_case = param_values[input.name]['__current_case__'] + is_valid = True + except Exception: + current_case = None + is_valid = False + if is_valid: + rval.append(dict(text=input.test_param.label, depth=depth, value=input.cases[current_case].value)) + rval.extend(inputs_recursive(input.cases[current_case].inputs, param_values[input.name], depth=depth + 1, upgrade_messages=upgrade_messages.get(input.name))) + else: + rval.append(dict(text=input.name, depth=depth, notes="The previously used value is no longer valid.", error=True)) + elif input.type == "upload_dataset": + rval.append(dict(text=input.group_title(param_values), depth=depth, value="%s uploaded datasets" % len(param_values[input.name]))) + elif input.type == "data": + value = [] + for i, element in enumerate(util.listify(param_values[input.name])): + if element.history_content_type == "dataset": + hda = element + encoded_id = trans.security.encode_id(hda.id) + value.append({"src": "hda", "id": encoded_id, "hid": hda.hid, "name": hda.name}) + else: + value.append({"hid": element.hid, "name": element.name}) + rval.append(dict(text=input.label, depth=depth, value=value)) + elif input.visible: + if hasattr(input, "label") and input.label: + label = input.label + else: + # value for label not required, fallback to input name (same as tool panel) + label = input.name + rval.append(dict(text=label, depth=depth, value=input.value_to_display_text(param_values[input.name]), notes=upgrade_messages.get(input.name, ''))) + else: + # Parameter does not have a stored value. + # Get parameter label. + if input.type == "conditional": + label = input.test_param.label + elif input.type == "repeat": + label = input.label() + else: + label = input.label or input.name + rval.append(dict(text=label, depth=depth, notes="not used (parameter was added after this job was run)")) + + return rval + + # Load the tool + toolbox = self.app.toolbox + tool = toolbox.get_tool(job.tool_id, job.tool_version) + assert tool is not None, 'Requested tool has not been loaded.' + + params_objects = None + upgrade_messages = {} + has_parameter_errors = False + + # Load parameter objects, if a parameter type has changed, it's possible for the value to no longer be valid + try: + params_objects = job.get_param_values(self.app, ignore_errors=False) + except Exception: + params_objects = job.get_param_values(self.app, ignore_errors=True) + # use different param_objects in the following line, since we want to display original values as much as possible + upgrade_messages = tool.check_and_update_param_values(job.get_param_values(self.app, ignore_errors=True), + trans, + update_values=False) + has_parameter_errors = True + + parameters = inputs_recursive(tool.inputs, params_objects, depth=1, upgrade_messages=upgrade_messages) + return {"parameters": parameters, "has_parameter_errors": has_parameter_errors} + @expose_api_anonymous def build_for_rerun(self, trans, id, **kwd): """ @@ -306,23 +464,18 @@ class JobController(BaseAPIController, UsesLibraryMixinItems): dataset_dict = dict(src="ldda", id=trans.security.encode_id(dataset.id)) return dict(name=job_dataset_association.name, dataset=dataset_dict) - def __get_job(self, trans, id): - try: - decoded_job_id = self.decode_id(id) - except Exception: - raise exceptions.MalformedId() - job = trans.sa_session.query(trans.app.model.Job).filter(trans.app.model.Job.id == decoded_job_id).first() - if job is None: - raise exceptions.ObjectNotFound() - belongs_to_user = (job.user == trans.user) if job.user else (job.session_id == trans.get_galaxy_session().id) - if not trans.user_is_admin and not belongs_to_user: - # Check access granted via output datasets. - if not job.output_datasets: - raise exceptions.ItemAccessibilityException("Job has no output datasets.") - for data_assoc in job.output_datasets: - if not self.dataset_manager.is_accessible(data_assoc.dataset.dataset, trans.user): - raise exceptions.ItemAccessibilityException("You are not allowed to rerun this job.") - return job + def __get_job(self, trans, job_id=None, dataset_id=None, **kwd): + if job_id is not None: + try: + decoded_job_id = self.decode_id(job_id) + except Exception: + raise exceptions.MalformedId() + return self.job_manager.get_accessible_job(trans, decoded_job_id) + else: + hda_ldda = kwd.get("hda_ldda", "hda") + # Following checks dataset accessible + dataset_instance = self.get_hda_or_ldda(trans, hda_ldda=hda_ldda, dataset_id=dataset_id) + return dataset_instance.creating_job @expose_api def create(self, trans, payload, **kwd): diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 9289ab0b1f5..8f9825ed845 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -890,6 +890,11 @@ def populate_api_routes(webapp, app): webapp.mapper.connect('resume', '/api/jobs/{id}/resume', controller='jobs', action='resume', conditions=dict(method=['PUT'])) webapp.mapper.connect('job_error', '/api/jobs/{id}/error', controller='jobs', action='error', conditions=dict(method=['POST'])) webapp.mapper.connect('common_problems', '/api/jobs/{id}/common_problems', controller='jobs', action='common_problems', conditions=dict(method=['GET'])) + # Job metrics and parameters by job id or dataset id (for slightly different accessibility checking) + webapp.mapper.connect('metrics', '/api/jobs/{job_id}/metrics', controller='jobs', action='metrics', conditions=dict(method=['GET'])) + webapp.mapper.connect('dataset_metrics', '/api/datasets/{dataset_id}/metrics', controller='jobs', action='metrics', conditions=dict(method=['GET'])) + webapp.mapper.connect('parameters_display', '/api/jobs/{job_id}/parameters_display', controller='jobs', action='parameters_display', conditions=dict(method=['GET'])) + webapp.mapper.connect('dataset_parameters_display', '/api/datasets/{dataset_id}/parameters_display', controller='jobs', action='parameters_display', conditions=dict(method=['GET'])) # Job files controllers. Only for consumption by remote job runners. webapp.mapper.resource('file', diff --git a/templates/show_params.mako b/templates/show_params.mako index daeeb837d84..2a4e8f3549c 100644 --- a/templates/show_params.mako +++ b/templates/show_params.mako @@ -9,133 +9,8 @@ text-align: center; background-color: #eee; } - - table.info_data_table { - table-layout: fixed; - word-break: break-word; - } - table.info_data_table td:nth-child(1) { - width: 25%; - } - -<%def name="inputs_recursive( input_params, param_values, depth=1, upgrade_messages=None )"> - <% - from galaxy.util import listify - if upgrade_messages is None: - upgrade_messages = {} - %> - %for input_index, input in enumerate( input_params.values() ): - %if input.name in param_values: - %if input.type == "repeat": - %for i in range( len(param_values[input.name]) ): - ${ inputs_recursive(input.inputs, param_values[input.name][i], depth=depth+1) } - %endfor - %elif input.type == "section": - - ## - ${inputs_recursive_indent( text=input.name, depth=depth )} - - - ${ inputs_recursive( input.inputs, param_values[input.name], depth=depth+1, upgrade_messages=upgrade_messages.get( input.name ) ) } - %elif input.type == "conditional": - <% - try: - current_case = param_values[input.name]['__current_case__'] - is_valid = True - except: - current_case = None - is_valid = False - %> - %if is_valid: - - ${ inputs_recursive_indent( text=input.test_param.label, depth=depth )} - ## - ${input.cases[current_case].value | h} - - - ${ inputs_recursive( input.cases[current_case].inputs, param_values[input.name], depth=depth+1, upgrade_messages=upgrade_messages.get( input.name ) ) } - %else: - - ${ inputs_recursive_indent( text=input.name, depth=depth )} - The previously used value is no longer valid - - - %endif - %elif input.type == "upload_dataset": - - ${inputs_recursive_indent( text=input.group_title( param_values ), depth=depth )} - ${ len( param_values[input.name] ) } uploaded datasets - - - ## files used for inputs - %elif input.type == "data": - - ${inputs_recursive_indent( text=input.label, depth=depth )} - - %for i, element in enumerate(listify(param_values[input.name])): - %if i > 0: - , - %endif - %if element.history_content_type == "dataset": - <% - hda = element - encoded_id = trans.security.encode_id( hda.id ) - show_params_url = h.url_for( controller='dataset', action='show_params', dataset_id=encoded_id ) - %> - ${hda.hid}: ${hda.name | h} - - %else: - ${element.hid}: ${element.name | h} - %endif - %endfor - - - - %elif input.visible: - <% - if hasattr( input, "label" ) and input.label: - label = input.label - else: - #value for label not required, fallback to input name (same as tool panel) - label = input.name - %> - - ${inputs_recursive_indent( text=label, depth=depth )} - ${input.value_to_display_text( param_values[input.name] ) | h} - ${ upgrade_messages.get( input.name, '' ) | h } - - %endif - %else: - ## Parameter does not have a stored value. - - <% - # Get parameter label. - if input.type == "conditional": - label = input.test_param.label - elif input.type == "repeat": - label = input.label() - else: - label = input.label or input.name - %> - ${inputs_recursive_indent( text=label, depth=depth )} - not used (parameter was added after this job was run) - - - %endif - - %endfor - - - ## function to add a indentation depending on the depth in a -<%def name="inputs_recursive_indent( text, depth )"> - - ${text | h} - - -

% if tool: ${tool.name | h} @@ -212,31 +87,13 @@ -

Tool Parameters

- - - - - - - - - - % if params_objects and tool: - ${ inputs_recursive( tool.inputs, params_objects, depth=1, upgrade_messages=upgrade_messages ) } - %elif params_objects is None: - - % else: - - % endif - -
Input ParameterValueNote for rerun
Unable to load parameters.
No parameters.
-%if has_parameter_errors: -
- ${ render_msg( 'One or more of your original parameters may no longer be valid or displayed properly.', status='warning' ) } +%if job: +
+
%endif +

Inheritance Chain

${hda.name | h}
@@ -256,26 +113,8 @@ ${ job.command_line | h } %endif %if job and (trans.user_is_admin or trans.app.config.expose_potentially_sensitive_job_metrics): -

Job Metrics

-<% job_metrics = trans.app.job_metrics %> -<% plugins = set([metric.plugin for metric in job.metrics]) %> - %for plugin in sorted(plugins): - %if trans.user_is_admin or plugin != 'env': -

${ plugin | h }

- - - <% - plugin_metrics = filter(lambda x: x.plugin == plugin, job.metrics) - plugin_metric_displays = [job_metrics.format( metric.plugin, metric.metric_name, metric.metric_value ) for metric in plugin_metrics] - plugin_metric_displays = sorted(plugin_metric_displays, key=lambda pair: pair[0]) # Sort on displayed title - %> - %for metric_title, metric_value in plugin_metric_displays: - - %endfor - -
${ metric_title | h }${ metric_value | h }
- %endif - %endfor +
+
%endif %if trans.user_is_admin: @@ -352,5 +191,7 @@ $(function(){ window.parent.Galaxy.currHistoryPanel.scrollToId( 'dataset-' + $( this ).data( 'hda-id' ) ); } }) + window.bundleEntries.mountJobMetrics(); + window.bundleEntries.mountJobParameters(); }); diff --git a/test/base/populators.py b/test/base/populators.py index e06b8d23f0f..0ee8b19d416 100644 --- a/test/base/populators.py +++ b/test/base/populators.py @@ -1372,7 +1372,7 @@ def load_data_dict(history_id, test_data, dataset_populator, dataset_collection_ def wait_on_state(state_func, desc="state", skip_states=["running", "queued", "new", "ready"], assert_ok=False, timeout=DEFAULT_TIMEOUT): def get_state(): response = state_func() - assert response.status_code == 200, "Failed to fetch state update while waiting." + assert response.status_code == 200, "Failed to fetch state update while waiting. [%s]" % response.content state = response.json()["state"] if state in skip_states: return None