Merge pull request #16020 from martenson/jobs-filtering

implement admin jobs filtering
This commit is contained in:
Dannon
2023-05-04 21:07:11 -04:00
committed by GitHub
9 changed files with 277 additions and 24 deletions
+26 -1
View File
@@ -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: {
@@ -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");
});
});
@@ -5,7 +5,7 @@
{{ message }}
</b-alert>
<Heading h2 size="md" separator>Job Lock</Heading>
<job-lock />
<JobLock />
<Heading h2 size="md" separator>Job Overview</Heading>
<p>
Below unfinished jobs are displayed (in the 'new', 'queued', 'running', or 'upload' states) and recently
@@ -36,9 +36,7 @@
</b-form-group>
</b-form>
<b-form-group description="Use strings or regular expressions to search jobs.">
<b-input-group id="filter-regex">
<b-form-input v-model="filter" placeholder="Type to Search" @keyup.esc.native="filter = ''" />
</b-input-group>
<IndexFilter v-bind="filterAttrs" id="job-search" v-model="filter" />
</b-form-group>
</b-col>
</b-row>
@@ -56,11 +54,10 @@
</b-form>
</transition>
<h3 class="mb-0 h-sm">Unfinished Jobs</h3>
<jobs-table
<JobsTable
v-model="jobsItemsModel"
:fields="unfinishedJobFields"
:items="unfinishedJobs"
:filter="filter"
:table-caption="runningTableCaption"
:no-items-message="runningNoJobsMessage"
:loading="loading"
@@ -78,19 +75,22 @@
:checked="allSelected"
:value="data.item['id']"></b-form-checkbox>
</template>
</jobs-table>
</JobsTable>
<template v-if="!showAllRunning">
<h3 class="mb-0 h-sm">Finished Jobs</h3>
<jobs-table
<JobsTable
:table-caption="finishedTableCaption"
:fields="finishedJobFields"
:items="finishedJobs"
:filter="filter"
:no-items-message="finishedNoJobsMessage"
:loading="loading"
:busy="busy">
</jobs-table>
: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)">
</JobsTable>
</template>
</div>
</template>
@@ -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 = `<div>
<p>This textbox box can be used to filter the jobs displayed.
<p>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
<code>&lt;tag_name&gt;:&lt;tag_value&gt;</code> or <code>&lt;tag_name&gt;:'&lt;tag_value&gt;'</code>.
For instance to search just for jobs with <code>cat1</code> in the tool name, <code>tool:cat1</code> can be used.
Notice by default the search is not case-sensitive.
<p>If the quoted version of tag is used, the search is case sensitive and only full matches will be
returned. So <code>tool:'cat1'</code> would show only jobs from the <code>cat1</code> tool exactly.</p>
<p>The available tags are:
<dl>
<dt><code>user</code></dt>
<dd>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.</dd>
<dt><code>handler</code></dt>
<dd>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.</dd>
<dt><code>runner</code></dt>
<dd>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.</dd>
<dt><code>tool</code></dt>
<dd>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.</dd>
</dl>
</div>
`;
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,
+33 -8
View File
@@ -1,16 +1,16 @@
<template>
<div>
<div class="jobs-table-wrapper">
<b-table
v-model="innerValue"
:fields="fields"
:items="items"
:filter="filter"
hover
responsive
no-sort-reset
striped
caption-top
:busy="busy"
class="jobs-table"
show-empty
@row-clicked="toggleDetails">
<template v-slot:table-caption>
@@ -23,10 +23,39 @@
</b-alert>
</template>
<template v-slot:cell(update_time)="data">
<utc-date :date="data.value" mode="elapsed" />
<UtcDate :date="data.value" mode="elapsed" />
</template>
<template v-slot:row-details="row">
<job-details :job="row.item" />
<JobDetails :job="row.item" />
</template>
<template v-slot:cell(user_email)="data">
<a class="job-filter-link-user" :data-user="data.value" @click="$emit('user-clicked', data.value)">{{
data.value
}}</a>
</template>
<template v-slot:cell(tool_id)="data">
<a
class="job-filter-link-tool-id"
:data-tool-id="data.value"
@click="$emit('tool-clicked', data.value)"
>{{ data.value }}</a
>
</template>
<template v-slot:cell(job_runner_name)="data">
<a
class="job-filter-link-runner"
:data-runner="data.value"
@click="$emit('runner-clicked', data.value)"
>{{ data.value }}</a
>
</template>
<template v-slot:cell(handler)="data">
<a
class="job-filter-link-handler"
:data-handler="data.handler"
@click="$emit('handler-clicked', data.value)"
>{{ data.value }}</a
>
</template>
<template v-for="(index, name) in $slots" v-slot:[name]>
<slot :name="name" />
@@ -56,10 +85,6 @@ export default {
items: {
required: true,
},
filter: {
type: String,
required: true,
},
busy: {
type: Boolean,
required: true,
@@ -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 },
@@ -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'
@@ -544,6 +550,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:
@@ -783,6 +790,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:
+35 -1
View File
@@ -234,7 +234,10 @@ class NavigatesGalaxy(HasDriver):
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 @@ class NavigatesGalaxy(HasDriver):
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 @@ class NavigatesGalaxy(HasDriver):
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 @@ class NotLoggedInException(SeleniumTimeoutException):
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)
@@ -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)
@@ -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