- 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-bottom: 0px; | ||||
margin-top: 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 { | .job-name { | ||||
font-size: var(--text-md); | 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 { | .no-background-jobs { | ||||
@@ -54,7 +43,5 @@ thead > tr > th:last-child { | |||||
} | } | ||||
.footer { | .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"> | <div class="exc_info"> | ||||
<pre>{{ frappe.utils.encode_tags(j.exc_info) }}</pre> | <pre>{{ frappe.utils.encode_tags(j.exc_info) }}</pre> | ||||
</div> | </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> | |||||
</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); | const background_job = new BackgroundJobs(wrapper); | ||||
$(wrapper).bind('show', () => { | |||||
$(wrapper).bind("show", () => { | |||||
background_job.show(); | background_job.show(); | ||||
}); | }); | ||||
@@ -12,61 +12,135 @@ class BackgroundJobs { | |||||
constructor(wrapper) { | constructor(wrapper) { | ||||
this.page = frappe.ui.make_app_page({ | this.page = frappe.ui.make_app_page({ | ||||
parent: wrapper, | parent: wrapper, | ||||
title: __('Background Jobs'), | |||||
title: __("Background Jobs"), | |||||
single_column: true | 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(); | this.refresh_jobs(); | ||||
} | } | ||||
}); | |||||
} | |||||
}); | }); | ||||
$(frappe.render_template('background_jobs_outer')).appendTo(this.page.body); | |||||
this.content = $(this.page.body).find('.table-area'); | |||||
} | } | ||||
show() { | show() { | ||||
this.refresh_jobs(); | this.refresh_jobs(); | ||||
this.update_scheduler_status(); | |||||
} | |||||
update_scheduler_status() { | |||||
frappe.call({ | 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() { | 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({ | 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); | setTimeout(() => this.refresh_jobs(), 2000); | ||||
} | } | ||||
} | } | ||||
@@ -8,8 +8,8 @@ from rq import Worker | |||||
import frappe | import frappe | ||||
from frappe import _ | 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 | from frappe.utils.scheduler import is_scheduler_inactive | ||||
if TYPE_CHECKING: | if TYPE_CHECKING: | ||||
@@ -24,16 +24,15 @@ JOB_COLORS = { | |||||
@frappe.whitelist() | @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 = [] | jobs = [] | ||||
def add_job(job: 'Job', name: str) -> None: | 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: | if job.kwargs.get('site') == frappe.local.site: | ||||
job_info = { | job_info = { | ||||
'job_name': job.kwargs.get('kwargs', {}).get('playbook_method') | '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')), | or str(job.kwargs.get('job_name')), | ||||
'status': job.get_status(), | 'status': job.get_status(), | ||||
'queue': name, | '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()] | 'color': JOB_COLORS[job.get_status()] | ||||
} | } | ||||
@@ -50,32 +49,31 @@ def get_info(show_failed=False) -> List[Dict]: | |||||
jobs.append(job_info) | 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: | for job in queue.jobs: | ||||
add_job(job, queue.name) | 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 | return jobs | ||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def remove_failed_jobs(): | def remove_failed_jobs(): | ||||
conn = get_redis_conn() | |||||
queues = get_queues() | queues = get_queues() | ||||
for queue in queues: | for queue in queues: | ||||
fail_registry = queue.failed_job_registry | fail_registry = queue.failed_job_registry | ||||
@@ -87,5 +85,5 @@ def remove_failed_jobs(): | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def get_scheduler_status(): | def get_scheduler_status(): | ||||
if is_scheduler_inactive(): | 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() { | get_form_values() { | ||||
var 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; | return values; | ||||
} | } | ||||
add_view(name, html) { | add_view(name, html) { | ||||
@@ -53,6 +53,7 @@ | |||||
.custom-actions { | .custom-actions { | ||||
display: flex; | display: flex; | ||||
align-items: center; | |||||
} | } | ||||
.page-actions { | .page-actions { | ||||
@@ -220,9 +220,12 @@ def get_queue_list(queue_list=None, build_queue_name=False): | |||||
queue_list = default_queue_list | queue_list = default_queue_list | ||||
return [generate_qname(qtype) for qtype in queue_list] if build_queue_name else 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): | def get_running_jobs_in_queue(queue): | ||||
'''Returns a list of Jobs objects that are tied to a queue object and are currently running''' | '''Returns a list of Jobs objects that are tied to a queue object and are currently running''' | ||||