diff --git a/client/src/components/Indices/filtersMixin.js b/client/src/components/Indices/filtersMixin.js
index 719297f4a4a3..845935c09513 100644
--- a/client/src/components/Indices/filtersMixin.js
+++ b/client/src/components/Indices/filtersMixin.js
@@ -4,14 +4,39 @@ export default {
components: {
IndexFilter,
},
+ props: {
+ inputDebounceDelay: {
+ type: Number,
+ default: 500,
+ },
+ },
data() {
return {
filter: "",
+ implicitFilter: null,
+ helpHtml: null,
};
},
computed: {
isFiltered() {
- return !!this.filter;
+ return Boolean(this.filter);
+ },
+ effectiveFilter() {
+ let filter = this.filter;
+ const implicitFilter = this.implicitFilter;
+ if (implicitFilter && filter) {
+ filter = `${implicitFilter} ${filter}`;
+ } else if (implicitFilter) {
+ filter = implicitFilter;
+ }
+ return filter;
+ },
+ filterAttrs() {
+ return {
+ "debounce-delay": this.inputDebounceDelay,
+ placeholder: this.titleSearch,
+ "help-html": this.helpHtml,
+ };
},
},
methods: {
diff --git a/client/src/components/Indices/filtersMixin.test.js b/client/src/components/Indices/filtersMixin.test.js
index 32d692b1c243..358b02dbcab6 100644
--- a/client/src/components/Indices/filtersMixin.test.js
+++ b/client/src/components/Indices/filtersMixin.test.js
@@ -43,4 +43,16 @@ describe("filtersMixin.js", () => {
wrapper.vm.appendTagFilter("name", "foobar");
expect(wrapper.vm.filter).toBe("name:'foobar'");
});
+
+ it("should have an effective filter that combines implicit and explicit filter", async () => {
+ wrapper.vm.implicitFilter = "tag:cowdog";
+ wrapper.vm.appendTagFilter("name", "foobar");
+ expect(wrapper.vm.filter).toBe("name:'foobar'");
+ expect(wrapper.vm.effectiveFilter).toBe("tag:cowdog name:'foobar'");
+ });
+
+ it("should just use implicit filter as effective if filter is empty", async () => {
+ wrapper.vm.implicitFilter = "tag:cowdog";
+ expect(wrapper.vm.effectiveFilter).toBe("tag:cowdog");
+ });
});
diff --git a/client/src/components/admin/Jobs.vue b/client/src/components/admin/JobsList.vue
similarity index 78%
rename from client/src/components/admin/Jobs.vue
rename to client/src/components/admin/JobsList.vue
index b526ed769813..9bb2a511c3a2 100644
--- a/client/src/components/admin/Jobs.vue
+++ b/client/src/components/admin/JobsList.vue
@@ -5,7 +5,7 @@
{{ message }}
Job Lock
-
+
Job Overview
Below unfinished jobs are displayed (in the 'new', 'queued', 'running', or 'upload' states) and recently
@@ -36,9 +36,7 @@
-
-
-
+
@@ -56,11 +54,10 @@
Unfinished Jobs
-
-
+
Finished Jobs
-
-
+ :busy="busy"
+ @tool-clicked="(toolId) => appendTagFilter('tool', toolId)"
+ @runner-clicked="(runner) => appendTagFilter('runner', runner)"
+ @handler-clicked="(handler) => appendTagFilter('handler', handler)"
+ @user-clicked="(user) => appendTagFilter('user', user)">
+
@@ -105,14 +105,42 @@ import { commonJobFields } from "./JobFields";
import { errorMessageAsString } from "utils/simple-error";
import { jobsProvider } from "components/providers/JobProvider";
import Heading from "components/Common/Heading";
+import filtersMixin from "components/Indices/filtersMixin";
function cancelJob(jobId, message) {
const url = `${getAppRoot()}api/jobs/${jobId}`;
return axios.delete(url, { data: { message: message } });
}
+const helpHtml = `
+
This textbox box can be used to filter the jobs displayed.
+
+
Text entered here will be searched against job user, tool ID, job runner, and handler. Additionally,
+advanced filtering tags can be used to refine the search more precisely. Tags are of the form
+<tag_name>:<tag_value>
or <tag_name>:'<tag_value>'
.
+For instance to search just for jobs with cat1
in the tool name, tool:cat1
can be used.
+Notice by default the search is not case-sensitive.
+
+
If the quoted version of tag is used, the search is case sensitive and only full matches will be
+returned. So tool:'cat1'
would show only jobs from the cat1
tool exactly.
+
+
The available tags are:
+
+ user
+ - This filters the job index to contain only jobs executed by matching user(s). You may also just click on a user in the list of jobs to filter on that exact user using this directly.
+ handler
+ - This filters the job index to contain only jobs executed on matching handler(s). You may also just click on a handler in the list of jobs to filter on that exact user using this directly.
+ runner
+ - This filters the job index to contain only jobs executed on matching job runner(s). You may also just click on a runner in the list of jobs to filter on that exact user using this directly.
+ tool
+ - This filters the job index to contain only jobs from the matching tool(s). You may also just click on a tool in the list of jobs to filter on that exact user using this directly.
+
+
+`;
+
export default {
components: { JobLock, JobsTable, Heading },
+ mixins: [filtersMixin],
data() {
return {
jobs: [],
@@ -130,13 +158,14 @@ export default {
allSelected: false,
indeterminate: false,
stopMessage: "",
- filter: "",
message: "",
status: "info",
loading: true,
busy: true,
cutoffMin: 5,
showAllRunning: false,
+ titleSearch: `search jobs`,
+ helpHtml: helpHtml,
};
},
computed: {
@@ -205,6 +234,9 @@ export default {
const dateRangeMin = new Date(Date.now() - cutoff * 60 * 1000).toISOString();
params.date_range_min = `${dateRangeMin}`;
}
+ if (this.filter) {
+ params.search = this.filter;
+ }
const ctx = {
root: getAppRoot(),
...params,
diff --git a/client/src/components/admin/JobsTable.vue b/client/src/components/admin/JobsTable.vue
index b6072d4d5087..9df6ac9597e8 100644
--- a/client/src/components/admin/JobsTable.vue
+++ b/client/src/components/admin/JobsTable.vue
@@ -1,16 +1,16 @@
-
+
@@ -23,10 +23,39 @@
-
+
-
+
+
+
+ {{
+ data.value
+ }}
+
+
+ {{ data.value }}
+
+
+ {{ data.value }}
+
+
+ {{ data.value }}
@@ -56,10 +85,6 @@ export default {
items: {
required: true,
},
- filter: {
- type: String,
- required: true,
- },
busy: {
type: Boolean,
required: true,
diff --git a/client/src/entry/analysis/routes/admin-routes.js b/client/src/entry/analysis/routes/admin-routes.js
index f0dba10c584c..4f5efdda691d 100644
--- a/client/src/entry/analysis/routes/admin-routes.js
+++ b/client/src/entry/analysis/routes/admin-routes.js
@@ -14,7 +14,7 @@ import DisplayApplications from "components/admin/DisplayApplications";
import ErrorStack from "components/admin/ErrorStack";
import FormGeneric from "components/Form/FormGeneric";
import Grid from "components/Grid/Grid";
-import Jobs from "components/admin/Jobs";
+import JobsList from "components/admin/JobsList";
import RegisterForm from "components/Login/RegisterForm";
import ResetMetadata from "components/admin/ResetMetadata";
import SanitizeAllow from "components/admin/SanitizeAllow";
@@ -42,7 +42,7 @@ export default [
{ path: "display_applications", component: DisplayApplications },
{ path: "error_stack", component: ErrorStack },
{ path: "invocations", component: ActiveInvocations },
- { path: "jobs", component: Jobs },
+ { path: "jobs", component: JobsList },
{ path: "reset_metadata", component: ResetMetadata },
{ path: "sanitize_allow", component: SanitizeAllow },
{ path: "toolbox_dependencies", component: ToolboxDependencies },
diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml
index d1801e345391..bbf210ba79c8 100644
--- a/client/src/utils/navigation/navigation.yml
+++ b/client/src/utils/navigation/navigation.yml
@@ -68,7 +68,9 @@ masthead:
# Shared data
libraries: 'Data Libraries'
+ published_workflows: 'Workflows'
published_histories: 'Histories'
+ published_pages: 'Pages'
preferences:
selectors:
@@ -480,6 +482,10 @@ pages:
create: '.manage-table-actions .action-button'
submit: '#submit'
export: '.markdown-pdf-export'
+ dropdown: '[data-page-dropdown*="${id}"]'
+ index_table: "#page-table"
+ index_rows: "#page-table > tbody > tr:not(.b-table-empty-row, [style*='display: none'])"
+
editor:
selectors:
save: '#save-button'
@@ -540,6 +546,7 @@ workflows:
pager_page_last: '.gx-workflows-grid-pager .gx-grid-pager-last button'
pager_page_previous: '.gx-workflows-grid-pager .gx-grid-pager-prev button'
pager_page_active: '.gx-workflows-grid-pager .gx-grid-pager-page.active button'
+ dropdown: '[data-workflow-dropdown*="${id}"]'
run_button: '[data-workflow-run*="${id}"]'
bookmark_link: '.workflow-bookmark-link'
workflow_with_name:
@@ -779,6 +786,16 @@ admin:
type: xpath
selector: "//label[@for='prevent-job-dispatching']/strong"
cutoff: '#cutoff'
+ table: '.jobs-table'
+ filter_link_user: '.job-filter-link-user'
+ filter_link_tool: '.job-filter-link-tool-id'
+ filter_link_handler: '.job-filter-link-handler'
+ filter_link_runner: '.job-filter-link-runner'
+ filter_link_by_user: '.job-filter-link-user[data-user="${user}"]'
+ filter_link_by_tool: '.job-filter-link-tool-id[data-tool-id="${tool_id}"]'
+ filter_link_by_handler: '.job-filter-link-handler[data-handler="${handler}"]'
+ filter_link_by_runner: '.job-filter-link-runner[data-runner="${runner}"]'
+ filter_query: '.index-filter-query'
toolshed:
selectors:
diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py
index 1b3d51274234..34ef41763e03 100644
--- a/lib/galaxy/selenium/navigates_galaxy.py
+++ b/lib/galaxy/selenium/navigates_galaxy.py
@@ -234,7 +234,10 @@ def timeout_for(self, wait_type: WaitType = DEFAULT_WAIT_TYPE, **kwd) -> float:
def home(self) -> None:
"""Return to root Galaxy page and wait for some basic widgets to appear."""
self.get()
- self.components.masthead._.wait_for_visible()
+ try:
+ self.components.masthead._.wait_for_visible()
+ except SeleniumTimeoutException as e:
+ raise ClientBuildException(e)
def go_to_trs_search(self) -> None:
self.driver.get(self.build_url("workflows/trs_search"))
@@ -1080,6 +1083,21 @@ def navigate_to_pages(self):
self.click_masthead_user()
self.components.masthead.pages.wait_for_and_click()
+ def navigate_to_published_workflows(self):
+ self.home()
+ self.click_masthead_shared_data()
+ self.components.masthead.published_workflows.wait_for_and_click()
+
+ def navigate_to_published_histories_page(self):
+ self.home()
+ self.click_masthead_shared_data()
+ self.components.masthead.published_histories.wait_for_and_click()
+
+ def navigate_to_published_pages(self):
+ self.home()
+ self.click_masthead_shared_data()
+ self.components.masthead.published_pages.wait_for_and_click()
+
def admin_open(self):
self.components.masthead.admin.wait_for_and_click()
@@ -1241,6 +1259,16 @@ def clear_tooltips(self):
action_chains.move_to_element(center_element).perform()
self.wait_for_selector_absent_or_hidden(".b-tooltip", wait_type=WAIT_TYPES.UX_POPUP)
+ def pages_index_table_elements(self):
+ pages = self.components.pages
+ pages.index_table.wait_for_visible()
+ return pages.index_rows.all()
+
+ def page_index_click_option(self, option_title, page_id):
+ self.components.pages.dropdown(id=page_id).wait_for_and_click()
+ if not self.select_dropdown_item(option_title):
+ raise AssertionError(f"Failed to find page action option with title [{option_title}]")
+
def workflow_index_open(self):
self.home()
self.click_masthead_workflow()
@@ -2065,3 +2093,9 @@ def __init__(self, timeout_exception, user_info, dom_message):
template = "Waiting for UI to reflect user logged in but it did not occur. API indicates no user is currently logged in. %s API response was [%s]. %s"
msg = template % (dom_message, user_info, timeout_exception.msg)
super().__init__(msg=msg, screen=timeout_exception.screen, stacktrace=timeout_exception.stacktrace)
+
+
+class ClientBuildException(SeleniumTimeoutException):
+ def __init__(self, timeout_exception: SeleniumTimeoutException):
+ msg = f"Error waiting for Galaxy masthead to appear, this frequently means there is a problem with the client build and the Galaxy client is broken. {timeout_exception.msg}"
+ super().__init__(msg=msg, screen=timeout_exception.screen, stacktrace=timeout_exception.stacktrace)
diff --git a/test/integration_selenium/test_admin_jobs.py b/test/integration_selenium/test_admin_jobs.py
new file mode 100644
index 000000000000..4e9cbce6c049
--- /dev/null
+++ b/test/integration_selenium/test_admin_jobs.py
@@ -0,0 +1,84 @@
+import os
+
+from galaxy_test.base.decorators import requires_admin
+from galaxy_test.base.populators import skip_without_tool
+from galaxy_test.base.workflow_fixtures import (
+ WORKFLOW_WITH_CUSTOM_REPORT_1,
+ WORKFLOW_WITH_CUSTOM_REPORT_1_TEST_DATA,
+)
+from galaxy_test.selenium.framework import retry_assertion_during_transitions
+from .framework import (
+ selenium_test,
+ SeleniumIntegrationTestCase,
+)
+
+SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__))
+JOB_CONFIG = os.path.join(SCRIPT_DIRECTORY, "test_admin_jobs_job_conf.yml")
+
+
+class TestAdminJobsGrid(SeleniumIntegrationTestCase):
+ run_as_admin = True
+
+ @classmethod
+ def handle_galaxy_config_kwds(cls, config):
+ super().handle_galaxy_config_kwds(config)
+ config["job_config_file"] = JOB_CONFIG
+
+ @selenium_test
+ @requires_admin
+ @skip_without_tool("cat")
+ def test_jobs_grid(self):
+ admin_component = self.components.admin
+ self.admin_login()
+ with self.dataset_populator.test_history() as history_id:
+ run_object = self.workflow_populator.run_workflow(
+ WORKFLOW_WITH_CUSTOM_REPORT_1, test_data=WORKFLOW_WITH_CUSTOM_REPORT_1_TEST_DATA, history_id=history_id
+ )
+ self.workflow_populator.wait_for_invocation_and_jobs(
+ history_id, run_object.workflow_id, run_object.invocation_id
+ )
+ self.admin_open()
+ admin_component.index.jobs.wait_for_and_click()
+ self.sleep_for(self.wait_types.UX_RENDER)
+ manage_jobs = admin_component.manage_jobs
+ manage_jobs.table.wait_for_visible()
+ self.screenshot("admin_jobs_custom_configuration")
+ self.sleep_for(self.wait_types.JOB_COMPLETION)
+ assert len(manage_jobs.filter_link_by_tool(tool_id="cat").all()) == 2
+ assert len(manage_jobs.filter_link_by_tool(tool_id="qc_stdout").all()) == 1
+ self.assert_displays_at_least_n_rows(3)
+
+ manage_jobs.filter_link_by_tool(tool_id="qc_stdout").wait_for_and_click()
+ assert manage_jobs.filter_query.wait_for_value() == "tool:'qc_stdout'"
+ self.assert_displays_n_rows(1)
+
+ self.clear_index_filter()
+ self.assert_displays_at_least_n_rows(3)
+
+ manage_jobs.filter_link_by_runner(runner="local_cat").wait_for_and_click()
+ self.assert_displays_n_rows(2)
+ self.clear_index_filter()
+
+ @retry_assertion_during_transitions
+ @requires_admin
+ def assert_displays_at_least_n_rows(self, n):
+ admin_component = self.components.admin
+ manage_jobs = admin_component.manage_jobs
+ assert len(manage_jobs.filter_link_user.all()) >= n
+
+ @retry_assertion_during_transitions
+ @requires_admin
+ def assert_displays_n_rows(self, n):
+ admin_component = self.components.admin
+ manage_jobs = admin_component.manage_jobs
+ assert len(manage_jobs.filter_link_user.all()) == n
+
+ @requires_admin
+ def clear_index_filter(self):
+ admin_component = self.components.admin
+ manage_jobs = admin_component.manage_jobs
+ # clear alone doesn't fire the debounce input...
+ element = manage_jobs.filter_query.wait_for_visible()
+ element.clear()
+ element.send_keys(" ")
+ self.send_enter(element)
diff --git a/test/integration_selenium/test_admin_jobs_job_conf.yml b/test/integration_selenium/test_admin_jobs_job_conf.yml
new file mode 100644
index 000000000000..e4d0332a33b9
--- /dev/null
+++ b/test/integration_selenium/test_admin_jobs_job_conf.yml
@@ -0,0 +1,24 @@
+runners:
+ local_qc:
+ load: galaxy.jobs.runners.local:LocalJobRunner
+ workers: 1
+ local_cat:
+ load: galaxy.jobs.runners.local:LocalJobRunner
+ workers: 1
+ local_upload:
+ load: galaxy.jobs.runners.local:LocalJobRunner
+ workers: 1
+execution:
+ default: local_upload
+ environments:
+ local_qc:
+ runner: local_qc
+ local_cat:
+ runner: local_cat
+ local_upload:
+ runner: local_upload
+tools:
+ - id: cat
+ environment: local_cat
+ - id: qc_stdout
+ environment: local_qc