- Jobs/Worker view - Filter by Queue Timeout and Job Status - Toggle Auto Refresh - Consistent themeversion-14
@@ -1,43 +1,32 @@ | |||
.list-jobs { | |||
font-size: var(--text-base); | |||
} | |||
.table { | |||
.table-background-jobs { | |||
margin-bottom: 0px; | |||
margin-top: 0px; | |||
font-size: var(--text-md); | |||
table-layout: fixed; | |||
} | |||
thead { | |||
background-color: var(--control-bg); | |||
border-radius: var(--border-radius-sm); | |||
.table-background-jobs th { | |||
font-weight: normal; | |||
color: var(--text-muted); | |||
} | |||
thead > tr { | |||
border-radius: var(--border-radius-sm); | |||
.table-background-jobs td { | |||
color: var(--text-light); | |||
} | |||
thead > tr > th:first-child { | |||
border-radius: var(--border-radius-sm) 0 0 var(--border-radius-sm); | |||
} | |||
thead > tr > th:last-child { | |||
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0; | |||
.table-background-jobs th, .table-background-jobs td { | |||
padding: var(--padding-sm) var(--padding-md); | |||
} | |||
.worker-name { | |||
display: flex; | |||
align-items: center; | |||
.table-background-jobs tbody tr:hover { | |||
background-color: var(--highlight-color); | |||
} | |||
.job-name { | |||
font-size: var(--text-md); | |||
font-family: "Courier New", Courier, monospace; | |||
/* background-color: var(--control-bg); */ | |||
/* padding: var(--padding-xs) var(--padding-sm); */ | |||
/* border-radius: var(--border-radius-md); */ | |||
} | |||
.background-job-row:hover { | |||
background-color: var(--bg-color); | |||
font-family: var(--font-family-monospace); | |||
word-break: break-word; | |||
} | |||
.no-background-jobs { | |||
@@ -54,7 +43,5 @@ thead > tr > th:last-child { | |||
} | |||
.footer { | |||
align-items: flex-end; | |||
margin-top: var(--margin-md); | |||
font-size: var(--text-base); | |||
padding: var(--padding-md); | |||
} |
@@ -1,51 +1,58 @@ | |||
<div class="list-jobs"> | |||
{% if jobs.length %} | |||
<table class="table table-borderless" style="table-layout: fixed;"> | |||
<thead> | |||
<tr> | |||
<th style="width: 20%">{{ __("Queue / Worker") }}</th> | |||
<th>{{ __("Job") }}</th> | |||
<th style="width: 15%">{{ __("Created") }}</th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{% for j in jobs %} | |||
<tr> | |||
<td class="worker-name"> | |||
<span class="indicator-pill no-margin {{ j.color }}"></span> | |||
<span class="ml-2">{{ j.queue.split(".").slice(-1)[0] }}</span> | |||
</td> | |||
<td style="overflow: auto;"> | |||
<div> | |||
<span class="job-name"> | |||
{{ frappe.utils.encode_tags(j.job_name) }} | |||
</span> | |||
</div> | |||
{% if j.exc_info %} | |||
{% if jobs.length %} | |||
<table class="table table-background-jobs"> | |||
<thead> | |||
<tr> | |||
<th style="width: 10%">{{ __("Queue") }}</th> | |||
<th>{{ __("Job") }}</th> | |||
<th style="width: 10%">{{ __("Status") }}</th> | |||
<th style="width: 15%">{{ __("Created") }}</th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{% for j in jobs %} | |||
<tr> | |||
<td class="worker-name"> | |||
{{ toTitle(j.queue.split(":").slice(-1)[0]) }} | |||
</td> | |||
<td> | |||
<div> | |||
<span class="job-name"> | |||
{{ frappe.utils.encode_tags(j.job_name) }} | |||
</span> | |||
</div> | |||
{% if j.exc_info %} | |||
<details> | |||
<summary>{{ __("Exception") }}</summary> | |||
<div class="exc_info"> | |||
<pre>{{ frappe.utils.encode_tags(j.exc_info) }}</pre> | |||
</div> | |||
{% endif %} | |||
</td> | |||
<td class="creation">{{ j.creation }}</td> | |||
</tr> | |||
{% endfor %} | |||
</tbody> | |||
</table> | |||
{% else %} | |||
<div class="no-background-jobs"> | |||
<img src="/assets/frappe/images/ui-states/list-empty-state.svg" alt="Empty State"> | |||
<p class="text-muted">{{ __("No pending or current jobs for this site") }}</p> | |||
</div> | |||
{% endif %} | |||
<div class="footer row"> | |||
<div class="col-md-6 text-muted text-center text-md-left">{{ __("Last refreshed") }} | |||
{{ frappe.datetime.now_datetime(true).toLocaleString() }}</div> | |||
<div class="col-md-6 text-center text-md-right"> | |||
<span class="indicator-pill blue" class="mr-2">{{ __("Started") }}</span> | |||
<span class="indicator-pill orange" class="mr-2">{{ __("Queued") }}</span> | |||
<span class="indicator-pill red" class="mr-2">{{ __("Failed") }}</span> | |||
<span class="indicator-pill green">{{ __("Finished") }}</span> | |||
</div> | |||
</details> | |||
{% endif %} | |||
</td> | |||
<td> | |||
<span class="indicator-pill {{ j.color }}"> | |||
{{ toTitle(j.status) }} | |||
</span> | |||
</td> | |||
<td class="creation text-muted"> | |||
{{ frappe.datetime.prettyDate(j.creation) }} | |||
</td> | |||
</tr> | |||
{% endfor %} | |||
</tbody> | |||
</table> | |||
{% else %} | |||
<div class="no-background-jobs"> | |||
<img | |||
src="/assets/frappe/images/ui-states/list-empty-state.svg" | |||
alt="Empty State" | |||
/> | |||
<p class="text-muted">{{ __("No jobs found on this site") }}</p> | |||
</div> | |||
{% endif %} | |||
<div class="footer"> | |||
<div class="text-muted"> | |||
{{ __("Last refreshed") }} | |||
{{ frappe.datetime.now_datetime(true).toLocaleString() }} | |||
</div> | |||
</div> | |||
</div> |
@@ -1,7 +1,7 @@ | |||
frappe.pages["background_jobs"].on_page_load = (wrapper) => { | |||
frappe.pages["background_jobs"].on_page_load = wrapper => { | |||
const background_job = new BackgroundJobs(wrapper); | |||
$(wrapper).bind('show', () => { | |||
$(wrapper).bind("show", () => { | |||
background_job.show(); | |||
}); | |||
@@ -12,61 +12,135 @@ class BackgroundJobs { | |||
constructor(wrapper) { | |||
this.page = frappe.ui.make_app_page({ | |||
parent: wrapper, | |||
title: __('Background Jobs'), | |||
title: __("Background Jobs"), | |||
single_column: true | |||
}); | |||
this.called = false; | |||
this.show_failed = false; | |||
this.page.add_inner_button(__("Remove Failed Jobs"), () => { | |||
frappe.confirm( | |||
__("Are you sure you want to remove all failed jobs?"), | |||
() => { | |||
frappe | |||
.call( | |||
"frappe.core.page.background_jobs.background_jobs.remove_failed_jobs" | |||
) | |||
.then(() => this.refresh_jobs()); | |||
} | |||
); | |||
}); | |||
this.show_failed_button = this.page.add_inner_button(__("Show Failed Jobs"), () => { | |||
this.show_failed = !this.show_failed; | |||
if (this.show_failed_button) { | |||
this.show_failed_button.text( | |||
this.show_failed ? __("Hide Failed Jobs") : __("Show Failed Jobs") | |||
); | |||
this.page.main.addClass("frappe-card"); | |||
this.page.body.append('<div class="table-area"></div>'); | |||
this.$content = $(this.page.body).find(".table-area"); | |||
this.make_filters(); | |||
this.refresh_jobs = frappe.utils.throttle( | |||
this.refresh_jobs.bind(this), | |||
1000 | |||
); | |||
} | |||
make_filters() { | |||
this.view = this.page.add_field({ | |||
label: __("View"), | |||
fieldname: "view", | |||
fieldtype: "Select", | |||
options: ["Jobs", "Workers"], | |||
default: "Jobs", | |||
change: () => { | |||
this.queue_timeout.toggle(this.view.get_value() === "Jobs"); | |||
this.job_status.toggle(this.view.get_value() === "Jobs"); | |||
} | |||
}); | |||
// add a "Remove Failed Jobs button" | |||
this.remove_failed_button = this.page.add_inner_button(__("Remove Failed Jobs"), () => { | |||
frappe.call({ | |||
method: 'frappe.core.page.background_jobs.background_jobs.remove_failed_jobs', | |||
callback: () => { | |||
this.queue_timeout = this.page.add_field({ | |||
label: __("Queue"), | |||
fieldname: "queue_timeout", | |||
fieldtype: "Select", | |||
options: [ | |||
{ label: "All Queues", value: "all" }, | |||
{ label: "Default", value: "default" }, | |||
{ label: "Short", value: "short" }, | |||
{ label: "Long", value: "long" } | |||
], | |||
default: "all" | |||
}); | |||
this.job_status = this.page.add_field({ | |||
label: __("Job Status"), | |||
fieldname: "job_status", | |||
fieldtype: "Select", | |||
options: [ | |||
{ label: "All Jobs", value: "all" }, | |||
{ label: "Queued", value: "queued" }, | |||
{ label: "Deferred", value: "deferred" }, | |||
{ label: "Started", value: "started" }, | |||
{ label: "Finished", value: "finished" }, | |||
{ label: "Failed", value: "failed" } | |||
], | |||
default: "all" | |||
}); | |||
this.auto_refresh = this.page.add_field({ | |||
label: __("Auto Refresh"), | |||
fieldname: "auto_refresh", | |||
fieldtype: "Check", | |||
default: 1, | |||
change: () => { | |||
if (this.auto_refresh.get_value()) { | |||
this.refresh_jobs(); | |||
} | |||
}); | |||
} | |||
}); | |||
$(frappe.render_template('background_jobs_outer')).appendTo(this.page.body); | |||
this.content = $(this.page.body).find('.table-area'); | |||
} | |||
show() { | |||
this.refresh_jobs(); | |||
this.update_scheduler_status(); | |||
} | |||
update_scheduler_status() { | |||
frappe.call({ | |||
method: 'frappe.core.page.background_jobs.background_jobs.get_scheduler_status', | |||
callback: res => { | |||
this.page.set_indicator(...res.message); | |||
method: | |||
"frappe.core.page.background_jobs.background_jobs.get_scheduler_status", | |||
callback: r => { | |||
let { status } = r.message; | |||
if (status === "active") { | |||
this.page.set_indicator(__("Scheduler: Active"), "green"); | |||
} else { | |||
this.page.set_indicator(__("Scheduler: Inactive"), "red"); | |||
} | |||
} | |||
}); | |||
} | |||
refresh_jobs() { | |||
if (this.called) return; | |||
this.called = true; | |||
let view = this.view.get_value(); | |||
let args; | |||
let { queue_timeout, job_status } = this.page.get_form_values(); | |||
if (view === "Jobs") { | |||
args = { view, queue_timeout, job_status }; | |||
} else { | |||
args = { view }; | |||
} | |||
this.page.add_inner_message(__("Refreshing...")); | |||
frappe.call({ | |||
method: 'frappe.core.page.background_jobs.background_jobs.get_info', | |||
args: { | |||
show_failed: this.show_failed | |||
}, | |||
callback: (res) => { | |||
this.called = false; | |||
this.page.body.find('.list-jobs').remove(); | |||
$(frappe.render_template('background_jobs', { jobs: res.message || [] })).appendTo(this.content); | |||
method: "frappe.core.page.background_jobs.background_jobs.get_info", | |||
args, | |||
callback: res => { | |||
this.page.add_inner_message(""); | |||
let template = | |||
view === "Jobs" ? "background_jobs" : "background_workers"; | |||
this.$content.html( | |||
frappe.render_template(template, { | |||
jobs: res.message || [] | |||
}) | |||
); | |||
if (frappe.get_route()[0] === 'background_jobs') { | |||
let auto_refresh = this.auto_refresh.get_value(); | |||
if ( | |||
frappe.get_route()[0] === "background_jobs" && | |||
auto_refresh | |||
) { | |||
setTimeout(() => this.refresh_jobs(), 2000); | |||
} | |||
} | |||
@@ -8,8 +8,8 @@ from rq import Worker | |||
import frappe | |||
from frappe import _ | |||
from frappe.utils import convert_utc_to_user_timezone, format_datetime | |||
from frappe.utils.background_jobs import get_redis_conn, get_queues | |||
from frappe.utils import convert_utc_to_user_timezone | |||
from frappe.utils.background_jobs import get_queues, get_workers | |||
from frappe.utils.scheduler import is_scheduler_inactive | |||
if TYPE_CHECKING: | |||
@@ -24,16 +24,15 @@ JOB_COLORS = { | |||
@frappe.whitelist() | |||
def get_info(show_failed=False) -> List[Dict]: | |||
if isinstance(show_failed, str): | |||
show_failed = json.loads(show_failed) | |||
conn = get_redis_conn() | |||
queues = get_queues() | |||
workers = Worker.all(conn) | |||
def get_info(view=None, queue_timeout=None, job_status=None) -> List[Dict]: | |||
jobs = [] | |||
def add_job(job: 'Job', name: str) -> None: | |||
if job_status != "all" and job.get_status() != job_status: | |||
return | |||
if queue_timeout != "all" and not name.endswith(f':{queue_timeout}'): | |||
return | |||
if job.kwargs.get('site') == frappe.local.site: | |||
job_info = { | |||
'job_name': job.kwargs.get('kwargs', {}).get('playbook_method') | |||
@@ -41,7 +40,7 @@ def get_info(show_failed=False) -> List[Dict]: | |||
or str(job.kwargs.get('job_name')), | |||
'status': job.get_status(), | |||
'queue': name, | |||
'creation': format_datetime(convert_utc_to_user_timezone(job.created_at)), | |||
'creation': convert_utc_to_user_timezone(job.created_at), | |||
'color': JOB_COLORS[job.get_status()] | |||
} | |||
@@ -50,32 +49,31 @@ def get_info(show_failed=False) -> List[Dict]: | |||
jobs.append(job_info) | |||
# show worker jobs | |||
for worker in workers: | |||
job = worker.get_current_job() | |||
if job: | |||
add_job(job, worker.name) | |||
for queue in queues: | |||
# show active queued jobs | |||
if queue.name != 'failed': | |||
if view == 'Jobs': | |||
queues = get_queues() | |||
for queue in queues: | |||
for job in queue.jobs: | |||
add_job(job, queue.name) | |||
# show failed jobs, if requested | |||
if show_failed: | |||
fail_registry = queue.failed_job_registry | |||
for job_id in fail_registry.get_job_ids(): | |||
job = queue.fetch_job(job_id) | |||
if job: | |||
add_job(job, queue.name) | |||
elif view == 'Workers': | |||
workers = get_workers() | |||
for worker in workers: | |||
current_job = worker.get_current_job() | |||
if current_job and current_job.kwargs.get('site') == frappe.local.site: | |||
add_job(current_job, job.origin) | |||
else: | |||
jobs.append({ | |||
'queue': worker.name, | |||
'job_name': 'idle', | |||
'status': '', | |||
'creation': '' | |||
}) | |||
return jobs | |||
@frappe.whitelist() | |||
def remove_failed_jobs(): | |||
conn = get_redis_conn() | |||
queues = get_queues() | |||
for queue in queues: | |||
fail_registry = queue.failed_job_registry | |||
@@ -87,5 +85,5 @@ def remove_failed_jobs(): | |||
@frappe.whitelist() | |||
def get_scheduler_status(): | |||
if is_scheduler_inactive(): | |||
return [_("Inactive"), "red"] | |||
return [_("Active"), "green"] | |||
return {'status': 'inactive'} | |||
return {'status': 'active'} |
@@ -1,5 +0,0 @@ | |||
<div class="frappe-card"> | |||
<div class="table-area"> | |||
</div> | |||
</div> |
@@ -0,0 +1,51 @@ | |||
{% if jobs.length %} | |||
<table class="table table-background-jobs"> | |||
<thead> | |||
<tr> | |||
<th style="width: 40%">{{ __("Worker") }}</th> | |||
<th>{{ __("Current Job") }}</th> | |||
<th style="width: 10%">{{ __("Status") }}</th> | |||
<th style="width: 15%">{{ __("Created") }}</th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{% for j in jobs %} | |||
<tr> | |||
<td class="worker-name"> | |||
{{ j.queue }} | |||
</td> | |||
<td> | |||
<div> | |||
<span class="job-name"> | |||
{{ frappe.utils.encode_tags(j.job_name) }} | |||
</span> | |||
</div> | |||
{% if j.exc_info %} | |||
<details> | |||
<summary>{{ __("Exception") }}</summary> | |||
<div class="exc_info"> | |||
<pre>{{ frappe.utils.encode_tags(j.exc_info) }}</pre> | |||
</div> | |||
</details> | |||
{% endif %} | |||
</td> | |||
<td> | |||
<span class="indicator-pill {{ j.color }}">{{ toTitle(j.status) }}</span> | |||
</td> | |||
<td class="creation text-muted">{{ frappe.datetime.prettyDate(j.creation) }}</td> | |||
</tr> | |||
{% endfor %} | |||
</tbody> | |||
</table> | |||
{% else %} | |||
<div class="no-background-jobs"> | |||
<img src="/assets/frappe/images/ui-states/list-empty-state.svg" alt="Empty State"> | |||
<p class="text-muted">{{ __("No workers online on this site") }}</p> | |||
</div> | |||
{% endif %} | |||
<div class="footer"> | |||
<div class="text-muted"> | |||
{{ __("Last refreshed") }} | |||
{{ frappe.datetime.now_datetime(true).toLocaleString() }} | |||
</div> | |||
</div> |
@@ -846,9 +846,10 @@ frappe.ui.Page = class Page { | |||
} | |||
get_form_values() { | |||
var values = {}; | |||
this.page_form.fields_dict.forEach(function(field, key) { | |||
values[key] = field.get_value(); | |||
}); | |||
for (let fieldname in this.fields_dict) { | |||
let field = this.fields_dict[fieldname]; | |||
values[fieldname] = field.get_value(); | |||
} | |||
return values; | |||
} | |||
add_view(name, html) { | |||
@@ -53,6 +53,7 @@ | |||
.custom-actions { | |||
display: flex; | |||
align-items: center; | |||
} | |||
.page-actions { | |||
@@ -220,9 +220,12 @@ def get_queue_list(queue_list=None, build_queue_name=False): | |||
queue_list = default_queue_list | |||
return [generate_qname(qtype) for qtype in queue_list] if build_queue_name else queue_list | |||
def get_workers(queue): | |||
'''Returns a list of Worker objects tied to a queue object''' | |||
return Worker.all(queue=queue) | |||
def get_workers(queue=None): | |||
'''Returns a list of Worker objects tied to a queue object if queue is passed, else returns a list of all workers''' | |||
if queue: | |||
return Worker.all(queue=queue) | |||
else: | |||
return Worker.all(get_redis_conn()) | |||
def get_running_jobs_in_queue(queue): | |||
'''Returns a list of Jobs objects that are tied to a queue object and are currently running''' | |||