From 657b7291774e68899cb15154e48948ad575fa09a Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Tue, 16 Jun 2026 16:02:02 +0000 Subject: [PATCH] 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. --- invoice_az/background_tasks.py | 173 +++++++++++ invoice_az/hooks.py | 2 +- .../public/js/background_tasks.bundle.js | 270 ++++++++++++++++++ 3 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 invoice_az/background_tasks.py create mode 100644 invoice_az/public/js/background_tasks.bundle.js diff --git a/invoice_az/background_tasks.py b/invoice_az/background_tasks.py new file mode 100644 index 0000000..bcaaa4d --- /dev/null +++ b/invoice_az/background_tasks.py @@ -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."), + } diff --git a/invoice_az/hooks.py b/invoice_az/hooks.py index 7af745f..f12f03b 100644 --- a/invoice_az/hooks.py +++ b/invoice_az/hooks.py @@ -114,7 +114,7 @@ fixtures = [] # include js, css files in header of desk.html # 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 # web_include_css = "/assets/invoice_az/css/invoice_az.css" diff --git a/invoice_az/public/js/background_tasks.bundle.js b/invoice_az/public/js/background_tasks.bundle.js new file mode 100644 index 0000000..0f7c9b6 --- /dev/null +++ b/invoice_az/public/js/background_tasks.bundle.js @@ -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")]) + + "

" + + __("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 = $('
').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 = $( + '
' + + '' + chev + "" + + '' + + '' + + "
" + ); + $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 = $('
'); + 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 = $('
'); + + const $top = $('
'); + $('').text(t.label || t.feature || __("Task")).appendTo($top); + $('').text(pct + "%").appendTo($top); + + const $cancel = $( + '' + ); + 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 = $('
'); + $('
').css("width", pct + "%").appendTo($bar); + $row.append($bar); + + const msg = status === "cancelling" ? __("Cancelling…") : (t.message || ""); + if (msg) { + $('
').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 ; 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); +})();