feat(background-tasks): per-user background job registry + global widget
Add a Redis-backed, per-user registry of in-flight background jobs and a desk-wide "Background Tasks" widget (bottom-right) that reads progress and can request best-effort cancellation. Wire the widget bundle via app_include_js.
This commit is contained in:
parent
be860259d7
commit
657b729177
|
|
@ -0,0 +1,173 @@
|
||||||
|
# ======= GLOBAL BACKGROUND TASK REGISTRY =======
|
||||||
|
#
|
||||||
|
# A lightweight, per-user registry of in-flight background jobs, backed by the
|
||||||
|
# Redis cache. It powers the desk-wide "Background Tasks" widget (bottom-right):
|
||||||
|
# every long-running worker writes a progress snapshot here, the widget reads it
|
||||||
|
# back and can request cancellation.
|
||||||
|
#
|
||||||
|
# Design notes:
|
||||||
|
# * Keyed by USER. `get_active_background_tasks()` / `cancel_background_task()`
|
||||||
|
# derive the user from `frappe.session.user` — the client never passes a user,
|
||||||
|
# so one user can neither see nor cancel another user's jobs.
|
||||||
|
# * The registry is the source of truth (survives page reloads). Realtime pings
|
||||||
|
# are only a "something changed, re-fetch" hint — anything published before the
|
||||||
|
# client subscribes is lost, so the widget ALSO polls this on a timer.
|
||||||
|
# * Cancellation is best-effort: the worker checks `is_cancel_requested()`
|
||||||
|
# between steps and bails out after finishing the current step.
|
||||||
|
# * Generalising this to the other bulk imports (sales/purchase/vat/amas) is
|
||||||
|
# ~10 lines each: new_job_id() -> register_task -> update_task ->
|
||||||
|
# is_cancel_requested -> finish_task.
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
REALTIME_EVENT = "background_tasks_changed"
|
||||||
|
|
||||||
|
_REGISTRY_TTL = 7200 # the per-user registry key self-expires after 2h
|
||||||
|
_CANCEL_TTL = 3600
|
||||||
|
|
||||||
|
|
||||||
|
# ======= INTERNAL HELPERS =======
|
||||||
|
|
||||||
|
def _registry_key(user):
|
||||||
|
return f"invoice_az:bg_tasks:{user}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_key(job_id):
|
||||||
|
return f"invoice_az:bg_cancel:{job_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(user):
|
||||||
|
return frappe.cache().get_value(_registry_key(user)) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(user, tasks):
|
||||||
|
frappe.cache().set_value(_registry_key(user), tasks, expires_in_sec=_REGISTRY_TTL)
|
||||||
|
|
||||||
|
|
||||||
|
def _ping(user, payload=None):
|
||||||
|
"""Hint the widget to re-fetch. `payload` may carry a finished task snapshot
|
||||||
|
so the widget can flash its final state before dropping it."""
|
||||||
|
frappe.publish_realtime(REALTIME_EVENT, payload or {}, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
# ======= PUBLIC WORKER-SIDE API =======
|
||||||
|
|
||||||
|
def new_job_id():
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def register_task(job_id, user, feature, label, total=0, cancellable=True):
|
||||||
|
"""Add a task to the user's registry in the `running` state."""
|
||||||
|
if not job_id:
|
||||||
|
return
|
||||||
|
tasks = _read(user)
|
||||||
|
tasks[job_id] = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"feature": feature,
|
||||||
|
"label": label,
|
||||||
|
"current": 0,
|
||||||
|
"total": total or 0,
|
||||||
|
"percent": 0,
|
||||||
|
"status": "running", # running | cancelling | done | error | cancelled
|
||||||
|
"cancellable": bool(cancellable),
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
_write(user, tasks)
|
||||||
|
_ping(user)
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(job_id, user, current=None, total=None, percent=None, message=None):
|
||||||
|
"""Update progress for a running task. Recomputes `percent` from
|
||||||
|
current/total unless an explicit `percent` is given."""
|
||||||
|
if not job_id:
|
||||||
|
return
|
||||||
|
tasks = _read(user)
|
||||||
|
task = tasks.get(job_id)
|
||||||
|
if not task:
|
||||||
|
return
|
||||||
|
if total is not None:
|
||||||
|
task["total"] = total or 0
|
||||||
|
if current is not None:
|
||||||
|
task["current"] = current
|
||||||
|
if message is not None:
|
||||||
|
task["message"] = message
|
||||||
|
if percent is not None:
|
||||||
|
task["percent"] = max(0, min(100, int(percent)))
|
||||||
|
elif task.get("total"):
|
||||||
|
task["percent"] = max(0, min(100, int(round(task["current"] / task["total"] * 100))))
|
||||||
|
tasks[job_id] = task
|
||||||
|
_write(user, tasks)
|
||||||
|
_ping(user)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_task(job_id, user, status="done", message=None):
|
||||||
|
"""Remove the task from the registry and flash its final state to the widget.
|
||||||
|
|
||||||
|
Removing it immediately keeps the poll-based fallback consistent: a task that
|
||||||
|
is gone from the registry is simply gone from the widget's list, so a lost
|
||||||
|
realtime ping can't leave a stale "running" row behind."""
|
||||||
|
if not job_id:
|
||||||
|
return
|
||||||
|
tasks = _read(user)
|
||||||
|
task = tasks.pop(job_id, None)
|
||||||
|
_write(user, tasks)
|
||||||
|
frappe.cache().delete_value(_cancel_key(job_id))
|
||||||
|
if task:
|
||||||
|
task["status"] = status
|
||||||
|
if message is not None:
|
||||||
|
task["message"] = message
|
||||||
|
if status == "done":
|
||||||
|
task["percent"] = 100
|
||||||
|
_ping(user, {"finished": task})
|
||||||
|
else:
|
||||||
|
_ping(user)
|
||||||
|
|
||||||
|
|
||||||
|
def request_cancel(job_id, user):
|
||||||
|
"""Set the cancel flag for a task owned by `user`. Returns False if the task
|
||||||
|
is not in this user's registry (so cross-user cancellation is impossible)."""
|
||||||
|
tasks = _read(user)
|
||||||
|
task = tasks.get(job_id)
|
||||||
|
if not task:
|
||||||
|
return False
|
||||||
|
frappe.cache().set_value(_cancel_key(job_id), 1, expires_in_sec=_CANCEL_TTL)
|
||||||
|
task["status"] = "cancelling"
|
||||||
|
task["message"] = "Cancellation requested"
|
||||||
|
tasks[job_id] = task
|
||||||
|
_write(user, tasks)
|
||||||
|
_ping(user)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_cancel_requested(job_id):
|
||||||
|
"""Fresh-from-Redis check the worker calls between steps.
|
||||||
|
|
||||||
|
CRITICAL: plain get_value() reads/writes frappe.local.cache, which pins the
|
||||||
|
first result (None) for the whole job — so a cancel flag set later by the web
|
||||||
|
request would never be seen. expires=True + use_local_cache=False forces a
|
||||||
|
real Redis read every call."""
|
||||||
|
return bool(
|
||||||
|
frappe.cache().get_value(_cancel_key(job_id), expires=True, use_local_cache=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======= WHITELISTED CLIENT-SIDE API =======
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_active_background_tasks():
|
||||||
|
"""Return the current user's in-flight tasks. Source of truth for the widget."""
|
||||||
|
return list(_read(frappe.session.user).values())
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def cancel_background_task(job_id):
|
||||||
|
"""Best-effort cancel of one of the current user's tasks."""
|
||||||
|
user = frappe.session.user
|
||||||
|
if not request_cancel(job_id, user):
|
||||||
|
return {"success": False, "message": frappe._("Task not found or already finished.")}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": frappe._("Cancellation requested. The job will stop after the current step."),
|
||||||
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ fixtures = []
|
||||||
|
|
||||||
# include js, css files in header of desk.html
|
# include js, css files in header of desk.html
|
||||||
# app_include_css = "/assets/invoice_az/css/invoice_az.css"
|
# app_include_css = "/assets/invoice_az/css/invoice_az.css"
|
||||||
# app_include_js = "/assets/invoice_az/js/invoice_az.js"
|
app_include_js = "background_tasks.bundle.js"
|
||||||
|
|
||||||
# include js, css files in header of web template
|
# include js, css files in header of web template
|
||||||
# web_include_css = "/assets/invoice_az/css/invoice_az.css"
|
# web_include_css = "/assets/invoice_az/css/invoice_az.css"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
// ======= GLOBAL BACKGROUND TASKS WIDGET =======
|
||||||
|
//
|
||||||
|
// A desk-wide, bottom-right widget that shows the current user's in-flight
|
||||||
|
// background jobs (data loading, imports, ...) with progress and per-task cancel.
|
||||||
|
//
|
||||||
|
// Behaviour (per product spec):
|
||||||
|
// * Appears automatically while any task is running.
|
||||||
|
// * Collapsible: a header "Background Tasks (N)" expands to the task list.
|
||||||
|
// * Each row has a progress bar + a red cancel button (with confirmation).
|
||||||
|
// * The header X hides the whole widget until the page is reloaded
|
||||||
|
// (dismissal is in-memory only — a reload brings it back if jobs run).
|
||||||
|
// * Disappears once no tasks remain.
|
||||||
|
//
|
||||||
|
// Reliability: realtime `background_tasks_changed` pings drive instant updates,
|
||||||
|
// but a poll on a timer is the source of truth (realtime events published before
|
||||||
|
// we subscribed are lost forever — same lesson as the AMAS import bar).
|
||||||
|
|
||||||
|
frappe.provide("invoice_az.bg_tasks");
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const FLASH_MS = 6000; // how long a finished row lingers before fading out
|
||||||
|
const FETCH_METHOD = "invoice_az.background_tasks.get_active_background_tasks";
|
||||||
|
const CANCEL_METHOD = "invoice_az.background_tasks.cancel_background_task";
|
||||||
|
const EVENT = "background_tasks_changed";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
started: false,
|
||||||
|
tasks: {}, // job_id -> running task snapshot (from the registry)
|
||||||
|
finished: {}, // job_id -> finished task snapshot (flashing, client-only)
|
||||||
|
dismissed: false, // header X — resets on reload
|
||||||
|
expanded: true,
|
||||||
|
$root: null,
|
||||||
|
pollTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- styles ----------
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById("iaz-bg-tasks-style")) return;
|
||||||
|
const css = `
|
||||||
|
.iaz-bg-tasks {
|
||||||
|
position: fixed; right: 16px; bottom: 16px; z-index: 1030;
|
||||||
|
width: 340px; max-width: calc(100vw - 32px);
|
||||||
|
background: var(--card-bg, #fff); color: var(--text-color, #1f272e);
|
||||||
|
border: 1px solid var(--border-color, #d1d8dd); border-radius: 10px;
|
||||||
|
box-shadow: 0 6px 24px rgba(0,0,0,.18); font-size: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.iaz-bg-tasks__header {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 9px 12px; cursor: pointer; user-select: none;
|
||||||
|
background: var(--subtle-fg, #f4f5f6); border-bottom: 1px solid var(--border-color, #e2e6e9);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.iaz-bg-tasks__title { flex: 1; }
|
||||||
|
.iaz-bg-tasks__chev { opacity: .7; }
|
||||||
|
.iaz-bg-tasks__close {
|
||||||
|
cursor: pointer; opacity: .6; padding: 0 4px; font-size: 15px; line-height: 1;
|
||||||
|
}
|
||||||
|
.iaz-bg-tasks__close:hover { opacity: 1; }
|
||||||
|
.iaz-bg-tasks__body { max-height: 50vh; overflow-y: auto; }
|
||||||
|
.iaz-bg-tasks__row { padding: 10px 12px; border-bottom: 1px solid var(--border-color, #f0f2f4); }
|
||||||
|
.iaz-bg-tasks__row:last-child { border-bottom: none; }
|
||||||
|
.iaz-bg-tasks__rowtop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.iaz-bg-tasks__label { flex: 1; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.iaz-bg-tasks__pct { opacity: .7; font-variant-numeric: tabular-nums; }
|
||||||
|
.iaz-bg-tasks__cancel {
|
||||||
|
cursor: pointer; border: none; background: transparent; color: #d9534f;
|
||||||
|
font-size: 14px; line-height: 1; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.iaz-bg-tasks__cancel[disabled] { opacity: .35; cursor: default; }
|
||||||
|
.iaz-bg-tasks__bar { position: relative; width: 100%; height: 6px; border-radius: 4px; background: var(--border-color, #e2e6e9); overflow: hidden; }
|
||||||
|
.iaz-bg-tasks__fill { position: absolute; left: 0; top: 0; height: 100%; width: 0; background: var(--primary, #2490ef); transition: width .25s ease; }
|
||||||
|
.iaz-bg-tasks__row--done .iaz-bg-tasks__fill { background: #28a745; }
|
||||||
|
.iaz-bg-tasks__row--cancelled .iaz-bg-tasks__fill,
|
||||||
|
.iaz-bg-tasks__row--error .iaz-bg-tasks__fill { background: #d9534f; }
|
||||||
|
.iaz-bg-tasks__msg { margin-top: 4px; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
`;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "iaz-bg-tasks-style";
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- data ----------
|
||||||
|
function refresh() {
|
||||||
|
frappe.call({
|
||||||
|
method: FETCH_METHOD,
|
||||||
|
callback: function (r) {
|
||||||
|
const list = (r && r.message) || [];
|
||||||
|
const incoming = {};
|
||||||
|
list.forEach(function (t) {
|
||||||
|
if (t && t.job_id) incoming[t.job_id] = t;
|
||||||
|
});
|
||||||
|
// A task we knew as running that vanished from the registry (e.g. a
|
||||||
|
// lost "finished" ping) is treated as completed — flash then drop.
|
||||||
|
Object.keys(state.tasks).forEach(function (jid) {
|
||||||
|
if (!incoming[jid] && !state.finished[jid]) {
|
||||||
|
const prev = state.tasks[jid];
|
||||||
|
flashFinished(Object.assign({}, prev, {
|
||||||
|
status: prev.status === "cancelling" ? "cancelled" : "done",
|
||||||
|
percent: prev.status === "cancelling" ? prev.percent : 100,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.tasks = incoming;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
error: function () {}, // silent — the poller must never nag the user
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPing(data) {
|
||||||
|
if (data && data.finished && data.finished.job_id) {
|
||||||
|
delete state.tasks[data.finished.job_id];
|
||||||
|
flashFinished(data.finished);
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashFinished(task) {
|
||||||
|
if (!task || !task.job_id) return;
|
||||||
|
state.finished[task.job_id] = task;
|
||||||
|
render();
|
||||||
|
setTimeout(function () {
|
||||||
|
delete state.finished[task.job_id];
|
||||||
|
render();
|
||||||
|
}, FLASH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTask(jobId, label) {
|
||||||
|
frappe.confirm(
|
||||||
|
__("Cancel background task: {0}?", [label || __("this task")]) +
|
||||||
|
"<br><br>" +
|
||||||
|
__("Data already loaded will be kept; the rest will be skipped."),
|
||||||
|
function () {
|
||||||
|
frappe.call({
|
||||||
|
method: CANCEL_METHOD,
|
||||||
|
args: { job_id: jobId },
|
||||||
|
callback: function (r) {
|
||||||
|
const m = (r && r.message) || {};
|
||||||
|
frappe.show_alert(
|
||||||
|
{
|
||||||
|
message: m.message || __("Cancellation requested."),
|
||||||
|
indicator: m.success ? "orange" : "red",
|
||||||
|
},
|
||||||
|
6
|
||||||
|
);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- view ----------
|
||||||
|
function removeRoot() {
|
||||||
|
if (state.$root) {
|
||||||
|
state.$root.remove();
|
||||||
|
state.$root = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRoot() {
|
||||||
|
if (state.$root) return;
|
||||||
|
state.$root = $('<div class="iaz-bg-tasks"></div>').appendTo(document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (state.dismissed) {
|
||||||
|
removeRoot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const running = Object.keys(state.tasks).map(function (k) { return state.tasks[k]; });
|
||||||
|
const finished = Object.keys(state.finished).map(function (k) { return state.finished[k]; });
|
||||||
|
const rows = running.concat(finished);
|
||||||
|
if (!rows.length) {
|
||||||
|
removeRoot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureRoot();
|
||||||
|
|
||||||
|
const chev = state.expanded ? "▾" : "▸";
|
||||||
|
const $root = state.$root;
|
||||||
|
$root.empty();
|
||||||
|
|
||||||
|
const $header = $(
|
||||||
|
'<div class="iaz-bg-tasks__header">' +
|
||||||
|
'<span class="iaz-bg-tasks__chev">' + chev + "</span>" +
|
||||||
|
'<span class="iaz-bg-tasks__title"></span>' +
|
||||||
|
'<span class="iaz-bg-tasks__close" title="' + frappe.utils.escape_html(__("Hide")) + '">✕</span>' +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
$header.find(".iaz-bg-tasks__title").text(__("Background Tasks ({0})", [running.length]));
|
||||||
|
$header.on("click", function (e) {
|
||||||
|
if ($(e.target).hasClass("iaz-bg-tasks__close")) return;
|
||||||
|
state.expanded = !state.expanded;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
$header.find(".iaz-bg-tasks__close").on("click", function () {
|
||||||
|
state.dismissed = true;
|
||||||
|
removeRoot();
|
||||||
|
});
|
||||||
|
$root.append($header);
|
||||||
|
|
||||||
|
if (!state.expanded) return;
|
||||||
|
|
||||||
|
const $body = $('<div class="iaz-bg-tasks__body"></div>');
|
||||||
|
rows.forEach(function (t) {
|
||||||
|
$body.append(buildRow(t));
|
||||||
|
});
|
||||||
|
$root.append($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(t) {
|
||||||
|
const status = t.status || "running";
|
||||||
|
const pct = Math.max(0, Math.min(100, t.percent || 0));
|
||||||
|
const canCancel = t.cancellable && (status === "running");
|
||||||
|
|
||||||
|
const $row = $('<div class="iaz-bg-tasks__row iaz-bg-tasks__row--' + status + '"></div>');
|
||||||
|
|
||||||
|
const $top = $('<div class="iaz-bg-tasks__rowtop"></div>');
|
||||||
|
$('<span class="iaz-bg-tasks__label"></span>').text(t.label || t.feature || __("Task")).appendTo($top);
|
||||||
|
$('<span class="iaz-bg-tasks__pct"></span>').text(pct + "%").appendTo($top);
|
||||||
|
|
||||||
|
const $cancel = $(
|
||||||
|
'<button class="iaz-bg-tasks__cancel" title="' +
|
||||||
|
frappe.utils.escape_html(__("Cancel")) + '">✕</button>'
|
||||||
|
);
|
||||||
|
if (!canCancel) {
|
||||||
|
$cancel.attr("disabled", "disabled");
|
||||||
|
} else {
|
||||||
|
$cancel.on("click", function () { cancelTask(t.job_id, t.label); });
|
||||||
|
}
|
||||||
|
$top.append($cancel);
|
||||||
|
$row.append($top);
|
||||||
|
|
||||||
|
const $bar = $('<div class="iaz-bg-tasks__bar"></div>');
|
||||||
|
$('<div class="iaz-bg-tasks__fill"></div>').css("width", pct + "%").appendTo($bar);
|
||||||
|
$row.append($bar);
|
||||||
|
|
||||||
|
const msg = status === "cancelling" ? __("Cancelling…") : (t.message || "");
|
||||||
|
if (msg) {
|
||||||
|
$('<div class="iaz-bg-tasks__msg"></div>').text(msg).appendTo($row);
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- bootstrap ----------
|
||||||
|
function init() {
|
||||||
|
if (state.started) return;
|
||||||
|
state.started = true;
|
||||||
|
injectStyles();
|
||||||
|
frappe.realtime.on(EVENT, onPing);
|
||||||
|
state.pollTimer = setInterval(refresh, POLL_MS);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
// app_include_js loads in the desk <head>; wait until frappe's realtime
|
||||||
|
// and call layers are ready before wiring up.
|
||||||
|
if (!window.frappe || !frappe.realtime || !frappe.call || !frappe.session || frappe.session.user === "Guest") {
|
||||||
|
setTimeout(boot, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice_az.bg_tasks.refresh = refresh; // exposed for manual debugging
|
||||||
|
$(document).ready(boot);
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue