Merge pull request #11868 from mvdbeek/handler_column_in_admin_jobs

Add handler column in admin jobs, remove webcontroller
This commit is contained in:
Dannon
2021-04-21 11:08:54 -04:00
committed by GitHub
11 changed files with 143 additions and 140 deletions
+1
View File
@@ -32,6 +32,7 @@
"bootstrap-vue": "^2.15.0",
"citation-js": "^0.5.0-alpha.5",
"d3": "3",
"date-fns": "^2.21.1",
"decode-uri-component": "^0.2.0",
"deep-diff": "^1.0.2",
"deep-equal": "^2.0.5",
@@ -30,6 +30,12 @@
<UtcDate :date="job.update_time" mode="pretty" />
</td>
</tr>
<tr v-if="job && includeTimes && jobIsTerminal">
<td>Time To Finish</td>
<td id="runtime">
{{ runTime }}
</td>
</tr>
<code-row id="command-line" v-if="job" :code-label="'Command Line'" :code-item="job.command_line" />
<code-row id="stdout" v-if="job" :code-label="'Tool Standard Output'" :code-item="job.tool_stdout" />
<code-row id="stderr" v-if="job" :code-label="'Tool Standard Error'" :code-item="job.tool_stderr" />
@@ -45,6 +51,7 @@
</ul>
</td>
</tr>
<slot></slot>
<tr v-if="job && job.id">
<td>Job API ID:</td>
<td id="encoded-job-id">{{ job.id }} <decoded-id :id="job.id" /></td>
@@ -67,6 +74,8 @@ import DecodedId from "../DecodedId.vue";
import CodeRow from "./CodeRow.vue";
import UtcDate from "components/UtcDate";
import CopyToClipboard from "components/CopyToClipboard";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import { formatDuration, intervalToDuration } from "date-fns";
export default {
components: {
@@ -90,8 +99,15 @@ export default {
},
computed: {
job: function () {
const job = this.$store.getters.job(this.job_id);
return job;
return this.$store.getters.job(this.job_id);
},
runTime: function () {
return formatDuration(
intervalToDuration({ start: new Date(this.job.create_time), end: new Date(this.job.update_time) })
);
},
jobIsTerminal() {
return !JOB_STATES_MODEL.NON_TERMINAL_STATES.includes(this.job.state);
},
},
methods: {
+28 -2
View File
@@ -1,8 +1,30 @@
<template>
<b-card>
<h5>Job Parameters</h5>
<job-information :job_id="job.id" :includeTimes="true">
<tr v-if="job.traceback">
<td>Traceback</td>
<td>
<code-row :code-label="'Traceback'" :code-item="job.traceback" />
</td>
</tr>
<tr v-if="job.info">
<td>Info</td>
<td>
<code-row :code-label="'Traceback'" :code-item="job.traceback" />
</td>
</tr>
<tr v-if="job.remote_host">
<td>Remote Host</td>
<td>
{{ job.remote_host }}
</td>
</tr>
</job-information>
<br />
<h3>Job Parameters</h3>
<job-parameters :job-id="job.id" :include-title="false" />
<h5>Job Metrics</h5>
<br />
<h3>Job Metrics</h3>
<job-metrics :job-id="job.id" :include-title="false" />
</b-card>
</template>
@@ -10,9 +32,13 @@
<script>
import { JobMetrics } from "components/JobMetrics";
import { JobParameters } from "components/JobParameters";
import JobInformation from "components/JobInformation/JobInformation";
import CodeRow from "components/JobInformation/CodeRow.vue";
export default {
components: {
CodeRow,
JobInformation,
JobMetrics,
JobParameters,
},
+10
View File
@@ -0,0 +1,10 @@
export const commonJobFields = [
{ key: "id", label: "Encoded Job ID", sortable: true },
{ key: "decoded_job_id", label: "Decoded Job ID", sortable: true },
{ key: "user_email" },
{ key: "tool_id", label: "Tool", tdClass: ["break-word"] },
{ key: "state" },
{ key: "handler" },
{ key: "job_runner_name", label: "Job Runner" },
{ key: "external_id", label: "PID/Cluster ID", sortable: true },
];
+43 -77
View File
@@ -57,7 +57,7 @@
</b-col>
</b-row>
<transition name="fade">
<b-form v-if="jobsItemsComputed.length && selectedStopJobIds.length" @submit.prevent="onStopJobs">
<b-form v-if="unfinishedJobs.length && selectedStopJobIds.length" @submit.prevent="onStopJobs">
<b-form-group label="Stop Selected Jobs" description="Stop message will be displayed to the user">
<b-input-group>
<b-form-input id="stop-message" v-model="stopMessage" placeholder="Stop message" required>
@@ -70,20 +70,20 @@
</b-form>
</transition>
<h4>Unfinished Jobs</h4>
<b-alert v-if="!jobsItemsComputed.length" variant="secondary" show>
<b-alert v-if="!unfinishedJobs.length" variant="secondary" show>
There are no unfinished jobs to show with current cutoff time of {{ cutoffMin }} minutes.
</b-alert>
<b-table
v-else
:fields="jobsFieldsComputed"
:items="jobsItemsComputed"
v-model="jobsItemsModel"
:fields="unfinishedJobFields"
:items="unfinishedJobs"
:filter="filter"
hover
responsive
striped
caption-top
@row-clicked="showRowDetails"
@row-clicked="toggleDetails"
:busy="busy"
>
<template v-slot:table-caption>
@@ -109,53 +109,38 @@
<template v-slot:cell(update_time)="data">
<utc-date :date="data.value" mode="elapsed" />
</template>
<template v-slot:cell(job_info)="data">
<b-link :href="data.value.info_url" @click.prevent="clickJobInfo(data.value.id)">
{{ data.value.id }}
</b-link>
</template>
<template v-slot:row-details="row">
<job-details :job="row.item" />
</template>
</b-table>
<h4>Finished Jobs</h4>
<b-alert v-if="!recentJobsItemsComputed.length" variant="secondary" show>
<b-alert v-if="!finishedJobs.length" variant="secondary" show>
There are no recently finished jobs to show with current cutoff time of {{ cutoffMin }} minutes.
</b-alert>
<b-table
v-else
:fields="recentJobsFieldsComputed"
:items="recentJobsItemsComputed"
:fields="finishedJobFields"
:items="finishedJobs"
:filter="filter"
hover
responsive
striped
caption-top
@row-clicked="showRowDetails"
@row-clicked="toggleDetails"
:busy="busy"
>
<template v-slot:table-caption>
These jobs have completed in the previous {{ cutoffMin }} minutes.
</template>
<template v-slot:cell(job_info)="data">
<b-link :href="data.value.info_url" @click.prevent="clickJobInfo(data.value.id)">
{{ data.value.id }}
</b-link>
</template>
<template v-slot:cell(update_time)="data">
<utc-date :date="data.value" mode="elapsed" />
</template>
<template v-slot:row-details="row">
<job-details :command-line="row.item.command_line" :job-id="row.item.id" />
<job-details :job="row.item" />
</template>
</b-table>
<b-modal ref="job-info-modal" scrollable hide-header ok-only @hidden="resetModalContents">
<div class="info-frame-container">
<iframe :src="selectedJobUrl"></iframe>
</div>
</b-modal>
</div>
</div>
</template>
@@ -166,6 +151,8 @@ import UtcDate from "components/UtcDate";
import axios from "axios";
import JobDetails from "./JobDetails";
import JobLock from "./JobLock";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import { commonJobFields } from "./JobFields";
function cancelJob(jobId, message) {
const url = `${getAppRoot()}api/jobs/${jobId}`;
@@ -176,16 +163,15 @@ export default {
components: { UtcDate, JobDetails, JobLock },
data() {
return {
jobsItems: [],
jobs: [],
finishedJobs: [],
unfinishedJobs: [],
jobsItemsModel: [],
recentJobsItems: [],
jobsFields: [
{ key: "job_info", label: "Job ID", sortable: true },
{ key: "user" },
{ key: "tool_id", label: "Tool", tdClass: ["break-word"] },
{ key: "state" },
{ key: "job_runner_name", label: "Job Runner" },
{ key: "job_runner_external_id", label: "PID/Cluster ID", sortable: true },
finishedJobFields: [...commonJobFields, { key: "update_time", label: "Finished", sortable: true }],
unfinishedJobFields: [
{ key: "selected", label: "" },
...commonJobFields,
{ key: "update_time", label: "Last Update", sortable: true },
],
selectedStopJobIds: [],
selectedJobId: null,
@@ -213,20 +199,34 @@ export default {
this.allSelected = false;
}
},
jobs(newVal) {
const unfinishedJobs = [];
const finishedJobs = [];
newVal.forEach((item) => {
item._cellVariants = { state: this.translateState(item.state) };
if (JOB_STATES_MODEL.NON_TERMINAL_STATES.includes(item.state)) {
unfinishedJobs.push(item);
} else {
finishedJobs.push(item);
}
});
this.unfinishedJobs = unfinishedJobs;
this.finishedJobs = finishedJobs;
},
},
methods: {
update() {
this.busy = true;
let params = [];
const cutoff = Math.floor(this.cutoffMin * 60);
params.push(`cutoff=${cutoff}`);
const cutoff = Math.floor(this.cutoffMin);
const dateRangeMin = new Date(Date.now() - cutoff * 60 * 1000).toISOString();
params.push(`date_range_min=${dateRangeMin}`);
params.push("view=admin_job_list");
params = params.join("&");
axios
.get(`${getAppRoot()}admin/jobs_list?${params}`)
.get(`${getAppRoot()}api/jobs?${params}`)
.then((response) => {
this.jobsItems = response.data.jobs;
this.recentJobsItems = response.data.recent_jobs;
this.cutoffMin = Math.floor(response.data.cutoff / 60);
this.jobs = response.data;
this.message = response.data.message;
this.status = response.data.status;
this.loading = false;
@@ -248,17 +248,9 @@ export default {
this.stopMessage = "";
});
},
clickJobInfo(id) {
this.selectedJobId = id;
this.$refs["job-info-modal"].show();
},
resetModalContents() {
this.selectedJobId = null;
},
showRowDetails(row, index, e) {
if (e.target.nodeName != "A") {
row._showDetails = !row._showDetails;
}
toggleDetails(item) {
this.$set(item, "_showDetails", !item._showDetails);
},
translateState(state) {
const translateDict = {
@@ -280,10 +272,6 @@ export default {
};
});
},
computeFields(fields) {
const f = Array.from(fields).slice(0);
return f;
},
toggleAll(checked) {
this.selectedStopJobIds = checked ? this.jobsItemsModel.reduce((acc, j) => [...acc, j["id"]], []) : [];
},
@@ -305,28 +293,6 @@ export default {
}
},
},
computed: {
jobsItemsComputed() {
return this.computeItems(this.jobsItems);
},
recentJobsItemsComputed() {
return this.computeItems(this.recentJobsItems);
},
jobsFieldsComputed() {
const f = this.jobsFields.slice(0);
f.splice(0, 0, { key: "selected", label: "" });
f.splice(2, 0, { key: "update_time", label: "Last Update", sortable: true });
return this.computeFields(f);
},
recentJobsFieldsComputed() {
const f = this.jobsFields.slice(0);
f.splice(2, 0, { key: "update_time", label: "Finished", sortable: true });
return this.computeFields(f);
},
selectedJobUrl() {
return `${getAppRoot()}admin/job_info?jobid=${this.selectedJobId}`;
},
},
created() {
this.update();
},
+1 -1
View File
@@ -8,7 +8,7 @@ import axios from "axios";
const getters = {
job: (state) => (jobId) => {
return state.job[jobId] || [];
return state.job[jobId];
},
};
+5
View File
@@ -4886,6 +4886,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
date-fns@^2.21.1:
version "2.21.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.21.1.tgz#679a4ccaa584c0706ea70b3fa92262ac3009d2b0"
integrity sha512-m1WR0xGiC6j6jNFAyW4Nvh4WxAi4JF4w9jRJwSI8nBmNcyZXPcP9VUQG+6gHQXAmqaGEKDKhOqAtENDC941UkA==
date-format@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
+14 -3
View File
@@ -1131,14 +1131,25 @@ class Job(JobLike, UsesCreateAndUpdateTime, Dictifiable, RepresentById):
return job_attrs
def to_dict(self, view='collection', system_details=False):
rval = super().to_dict(view=view)
if view == 'admin_job_list':
rval = super().to_dict(view='collection')
else:
rval = super().to_dict(view=view)
rval['tool_id'] = self.tool_id
rval['history_id'] = self.history_id
if system_details:
if system_details or view == 'admin_job_list':
# System level details that only admins should have.
rval['external_id'] = self.job_runner_external_id
rval['command_line'] = self.command_line
if view == 'admin_job_list':
rval['user_email'] = self.user.email if self.user else None
rval['handler'] = self.handler
rval['job_runner_name'] = self.job_runner_name
rval['info'] = self.info
rval['traceback'] = self.traceback
rval['session_id'] = self.session_id
if self.galaxy_session and self.galaxy_session.remote_host:
rval['remote_host'] = self.galaxy_session.remote_host
if view == 'element':
param_dict = {p.name: p.value for p in self.parameters}
rval['params'] = param_dict
+11 -1
View File
@@ -121,6 +121,9 @@ class JobController(BaseGalaxyAPIController, UsesVisualizationMixin):
:type invocation_id: string
:param invocation_id: limit listing of jobs to those that match the invocation_id. If none, all are returned.
:type view: string
:param view: Determines columns to return. Defaults to 'collection'.
:rtype: list
:returns: list of dictionaries containing summary job information
"""
@@ -128,6 +131,11 @@ class JobController(BaseGalaxyAPIController, UsesVisualizationMixin):
is_admin = trans.user_is_admin
user_details = kwd.get('user_details', False)
user_id = kwd.get('user_id', None)
view = kwd.get('view', 'collection')
if view not in ('collection', 'admin_job_list'):
raise exceptions.RequestParameterInvalidException(f"view parameter '{view} is invalid")
if view == 'admin_job_list' and not is_admin:
raise exceptions.AdminRequiredException("Only admins can use the admin_job_list view")
if user_id:
decoded_user_id = self.decode_id(user_id)
@@ -197,8 +205,10 @@ class JobController(BaseGalaxyAPIController, UsesVisualizationMixin):
out = []
for job in query.all():
job_dict = job.to_dict('collection', system_details=is_admin)
job_dict = job.to_dict(view, system_details=is_admin)
j = self.encode_all_ids(trans, job_dict, True)
if view == 'admin_job_list':
j['decoded_job_id'] = job.id
if user_details:
j['user_email'] = job.user.email
out.append(j)
+1 -54
View File
@@ -1,9 +1,8 @@
import imp
import logging
import os
from datetime import datetime, timedelta
from sqlalchemy import and_, false, or_
from sqlalchemy import false
from galaxy import (
model,
@@ -1519,58 +1518,6 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP
trans.sa_session.refresh(user)
return {'message': f"User '{user.email}' has been updated with {len(in_roles) - 1} associated roles and {len(in_groups)} associated groups (private roles are not displayed)."}
@web.expose
@web.json
@web.require_admin
def jobs_list(self, trans, cutoff=180, **kwd):
message = kwd.get('message', '')
status = kwd.get('status', 'info')
cutoff_time = datetime.utcnow() - timedelta(seconds=int(cutoff))
jobs = trans.sa_session.query(trans.app.model.Job) \
.filter(and_(trans.app.model.Job.table.c.update_time > cutoff_time,
or_(trans.app.model.Job.state == trans.app.model.Job.states.NEW,
trans.app.model.Job.state == trans.app.model.Job.states.QUEUED,
trans.app.model.Job.state == trans.app.model.Job.states.RUNNING,
trans.app.model.Job.state == trans.app.model.Job.states.UPLOAD))) \
.order_by(trans.app.model.Job.table.c.update_time.desc()).all()
recent_jobs = trans.sa_session.query(trans.app.model.Job) \
.filter(and_(trans.app.model.Job.table.c.update_time > cutoff_time,
or_(trans.app.model.Job.state == trans.app.model.Job.states.ERROR,
trans.app.model.Job.state == trans.app.model.Job.states.OK))) \
.order_by(trans.app.model.Job.table.c.update_time.desc()).all()
def prepare_jobs_list(jobs):
res = []
for job in jobs:
res.append({
'job_info': {
'id': job.id,
},
'id': trans.security.encode_id(job.id),
'user': job.history.user.email if job.history and job.history.user else 'anonymous',
'update_time': job.update_time.isoformat(),
'tool_id': job.tool_id,
'state': job.state,
'command_line': job.command_line,
'job_runner_name': job.job_runner_name,
'job_runner_external_id': job.job_runner_external_id
})
return res
return {'jobs': prepare_jobs_list(jobs),
'recent_jobs': prepare_jobs_list(recent_jobs),
'cutoff': cutoff,
'message': message,
'status': status}
@web.expose
@web.require_admin
def job_info(self, trans, jobid=None):
job = None
if jobid is not None:
job = trans.sa_session.query(trans.app.model.Job).get(jobid)
return trans.fill_template('/webapps/reports/job_info.mako',
job=job)
@web.expose
@web.require_admin
def manage_tool_dependencies(self,
+11
View File
@@ -46,6 +46,17 @@ class JobsApiTestCase(ApiTestCase, TestsTools):
job = jobs[0]
self._assert_has_keys(job, "command_line", "external_id")
@uses_test_history(require_new=True)
def test_admin_job_list(self, history_id):
self.__history_with_new_dataset(history_id)
jobs_response = self._get("jobs?view=admin_job_list", admin=False)
assert jobs_response.status_code == 403
assert jobs_response.json()['err_msg'] == 'Only admins can use the admin_job_list view'
jobs = self._get("jobs?view=admin_job_list", admin=True).json()
job = jobs[0]
self._assert_has_keys(job, "command_line", "external_id", 'handler')
@uses_test_history(require_new=True)
def test_index_state_filter(self, history_id):
# Initial number of ok jobs