diff --git a/.codex/coderabbit-fixes-wip.md b/.codex/coderabbit-fixes-wip.md
new file mode 100644
index 0000000000..b24ef5b01f
--- /dev/null
+++ b/.codex/coderabbit-fixes-wip.md
@@ -0,0 +1,60 @@
+# CodeRabbit Fixes WIP
+
+## Context
+
+- Repo: unraid/webgui
+- Branch: feat/backend-task-queue
+- PR: 2665
+- PR URL: https://github.com/unraid/webgui/pull/2665
+- Generated at: 2026-06-18
+
+## Inputs Pulled
+
+- [x] Unresolved robot review threads pulled (7)
+- [x] Top-level robot review notes and PR conversation comments pulled
+- [x] Top-level actionable review-body/PR comments extracted into queue (5 nitpicks)
+- [x] User asked whether to include human review comments
+- [x] Human review comments included in queue: no (user chose robot-only)
+- [x] Existing non-CodeRabbit thread replies checked before adding duplicate feedback (none from bots/humans; only a github-actions test-plugin notice)
+
+## Fix Queue
+
+| Item ID | Type | File | Line | Summary | Status | Link | Evidence |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| CR-001 | thread | BodyInlineJS.php | 214 | XSS in swal title via task.title (html:true) | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502042 | escapeTaskHtml(task.title) in swal title; php -l OK |
+| CR-002 | thread | BodyInlineJS.php | 279-291 | t.id unescaped in onclick handlers | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502054 | safeId=escapeTaskHtml(t.id) used in all 5 handlers; php -l OK |
+| CR-003 | thread | HeadInlineJS.php | 183-192 | createTask silent failure on POST error | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502071 | .fail() hides spinner + error swal; php -l OK |
+| CR-004 | thread | TaskQueue.php | 123 | Command injection via unescaped $args in bash -c | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502088 | escapeshellarg() over whole bash -c payload (preserves multi-arg word-split); reply posted explaining deviation from literal suggestion; php -l OK |
+| CR-005 | thread | nchan/tasks | 27-30 | Empty/non-numeric PID breaks /proc liveness check | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502115 | ctype_digit() guard before file_exists; php -l OK |
+| CR-006 | thread | default-base.css | 1919-1924 | Add min-width:0 for reliable ellipsis | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502123 | min-width:0 added to .op-tray .op-title |
+| CR-007 | thread | nchan/tasks | 62-67 | Daemon sleeps forever on queued-only restart | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3437357364 | queued-without-running recovery pass before advance/active check; php -l OK |
+| RVW-001 | review-body | TaskQueue.php | 147-177 | Race: concurrent create/launch can break one-running-per-type | DONE | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | per-type flock around dedupe->create->launch; php -l OK |
+| RVW-002 | review-body | TaskCommand.php | 35-51 | abort/dismiss return no JSON body | BLOCKED | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | Declined: response body is unused; canonical success signal is the `tasks` nchan broadcast. Adding unused API surface conflicts with no-speculative-contract policy. Reply: https://github.com/unraid/webgui/pull/2665#issuecomment-4745856704 |
+| RVW-003 | review-body | TaskCommand.php | 38-44 | Validate PID numeric before kill | DONE | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | ctype_digit()+(int)>1 guard before kill; php -l OK |
+| RVW-004 | review-body | nchan/tasks | 32-37 | _ERROR_ marker substring false positives | DONE | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | match _ERROR_ as discrete \x1e record (canonical sentinel, same as routeMessage); reads tail in PHP, removing exec last-line fragility; php -l OK |
+| RVW-005 | review-body | BodyInlineJS.php | 126-177 | Unescaped HTML in nchan message rendering | BLOCKED | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | Declined: renderMessage is a deliberate HTML/span protocol (addToID injects ``); blanket escaping breaks the live-log structure. Data originates from trusted server-side processes on a server-controlled channel, not untrusted client input. Reply posted. |
+
+## Execution Log
+
+1. CR-001 swal title — escaped task.title with escapeTaskHtml. DONE.
+2. CR-002 onclick ids — introduced safeId=escapeTaskHtml(t.id), used everywhere. DONE.
+3. CR-003 createTask — added .fail() with spinner hide + error swal. DONE.
+4. CR-004 command injection — wrapped the whole `sleep .3 && $name $args$suffix` payload in escapeshellarg() (superior to literal escapeshellarg($args), which would collapse multiple space-separated args). DONE + thread reply.
+5. CR-005 task_pid_alive — ctype_digit() numeric guard. DONE.
+6. CR-007 queued-only recovery — added recovery pass + $changed=true so launch publishes. DONE.
+7. RVW-004 _ERROR_ — discrete \x1e record match read in PHP. DONE.
+8. CR-006 CSS — min-width:0. DONE.
+9. RVW-001 race — per-type flock around critical section, released on every return path. DONE.
+10. RVW-003 PID kill — ctype_digit()+(int)>1. DONE.
+11. RVW-002 — BLOCKED (unused response surface). Reply posted.
+12. RVW-005 — BLOCKED (deliberate HTML protocol, trusted source). Reply posted.
+
+Validation: `php -l` clean on TaskQueue.php, TaskCommand.php, nchan/tasks, BodyInlineJS.php, HeadInlineJS.php.
+
+## Final Checks
+
+- [x] Queue reviewed: no `TODO` left
+- [x] Remaining `BLOCKED` items documented with reason (RVW-002, RVW-005)
+- [x] Every `BLOCKED`/not-valid CodeRabbit suggestion has a PR reply with the reason it was not applied
+- [x] Re-pulled CodeRabbit threads and reviews
+- [x] No unhandled top-level review-body comment remains
diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page
index e4a30d1776..c518ba7841 100755
--- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page
+++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page
@@ -4,6 +4,7 @@ Title="Installed Plugins"
Tag="icon-plugins"
Tabs="true"
Code="e944"
+Markdown="false"
---
");
diff --git a/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php b/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php
index 95428cb640..45b0f0babc 100644
--- a/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php
+++ b/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php
@@ -13,6 +13,18 @@
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
require_once "$docroot/webGui/include/Wrappers.php";
+require_once "$docroot/plugins/dynamix/include/TaskQueue.php";
+
+// true when the backend task queue has a running/queued task targeting this .plg
+// (the task queue is the source of truth for an in-flight install/update)
+function plugin_task_busy($arg) {
+ if (!$arg) return false;
+ foreach (task_list() as $t) {
+ if ($t['type']==='plugins' && in_array($t['status'],['running','queued'],true)
+ && in_array($arg, preg_split('/\s+/', trim($t['cmd'])), true)) return true;
+ }
+ return false;
+}
// Invoke the plugin command with indicated method
function plugin($method, $arg = '', $dontCache = false) {
@@ -71,7 +83,10 @@ function make_link($method, $arg, $extra='') {
$cmd = "plugin $method $arg".($extra?" $extra":"");
$func = "loadlist";
}
- if (is_file("/tmp/plugins/pluginPending/$arg") && !$check) {
+ if (in_array($method,['update','install']) && plugin_task_busy($arg)) {
+ $label = $method=='install' ? _('Installing') : _('Upgrading');
+ return " $label";
+ } elseif (is_file("/tmp/plugins/pluginPending/$arg") && !$check) {
return " "._('pending')."";
} else {
return "$check";
diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php
index eb40e02b7d..b5891233f3 100644
--- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php
+++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php
@@ -211,22 +211,46 @@ function foregroundTask(id) {
foregroundType = task.type;
stopAllTypeChannels();
clearProgressDots();
- var showConfirm = task.type=='plugins' ? task.button==0 : task.button!=0;
- var disable = task.type=='plugins' ? task.button!=0 : task.button==0;
- var titleState = task.status=='done' ? "=_('Finished')?>"
- : task.status=='error' ? "=_('Error')?>"
- : "=_('In Progress')?> ";
- swal({title:task.title + ' - '+titleState+'',text:"
",html:true,animation:'none',showConfirmButton:showConfirm,confirmButtonText:"=_('Close')?>"},function(close){
+ // Drive the modal by task status, not the per-type `button` flag (which made
+ // docker ops hide the Close button while running and disabled the confirm
+ // button, surfacing SweetAlert's la-ball-fall "bouncing dots" loader):
+ // running -> a top-corner minimize (below) backgrounds it; no disabled
+ // button, so the bouncing-dots loader never shows. The spinning
+ // title icon is the in-progress indicator.
+ // finished -> a primary Dismiss button clears the task from the tray.
+ var finished = task.status=='done' || task.status=='error';
+ // status renders as a colored "state" strip below the title (see .nchan-state)
+ var stateCls = task.status=='done' ? 'nchan-done'
+ : task.status=='error' ? 'nchan-error' : 'nchan-running';
+ var stateHtml = task.status=='done' ? " =_('Finished')?>"
+ : task.status=='error' ? " =_('Error')?>"
+ : " =_('In Progress')?>";
+ swal({title:escapeTaskHtml(task.title),text:"",html:true,animation:'none',showConfirmButton:finished,confirmButtonText:"=_('Dismiss')?>"},function(close){
+ // confirm/Dismiss (or Esc): background while running, clear once finished
if (foregroundTaskId===id) { foregroundTaskId=null; foregroundType=null; }
stopAllTypeChannels();
clearProgressDots();
- $('.sweet-alert').hide('fast').removeClass('nchan');
var fresh = taskById(id);
- if (fresh && (fresh.status=='done'||fresh.status=='error')) fireTaskCallback(fresh);
+ if (fresh && (fresh.status=='done'||fresh.status=='error')) { fireTaskCallback(fresh); dismissTask(id); }
+ nchanCloseModal(false); // swal closes via closeOnConfirm; this just cleans up
trayRender();
});
- $('.sweet-alert').addClass('nchan');
- $('button.confirm').prop('disabled',disable);
+ $('.sweet-alert').addClass('nchan').css('pointer-events','');
+ // colored state strip between the title and the log (openDone/openError recolor it)
+ $('.sweet-alert .nchan-state').remove();
+ $('.sweet-alert > h2').after("
"+stateHtml+"
");
+ // a persistent top-corner control that just closes this window, leaving the
+ // task in the tray: while running it reads as "minimize" (the task keeps
+ // running); once finished it reads as "close" (the task stays as a finished
+ // tile). Removal is the separate, primary Dismiss action. openDone/openError
+ // swap the glyph/tooltip to the finished form.
+ // the corner control is ALWAYS minimize (it tucks the modal away and keeps the
+ // task in the tray); removal is the separate Dismiss button. Keeping one icon
+ // avoids the confusing minus->x swap where the "x" actually just minimized.
+ var closeIcon = 'fa-minus';
+ var closeTip = finished ? "=_('Minimize - keeps it in the tray')?>" : "=_('Minimize - keeps running in the background')?>";
+ $('.sweet-alert .nchan-close').remove();
+ $('.sweet-alert').append("");
$('pre#swaltext').html('');
$.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata){
if (foregroundTaskId!==id) return; // user moved on while loading
@@ -257,7 +281,7 @@ function onTaskListUpdate() {
fireTaskCallback(t);
}
} else if (t.status=='running' && foregroundTaskId==t.id) {
- $('#pluginProgressTitle').html("=_('In Progress')?> ");
+ $('#pluginProgressTitle').attr('class','nchan-state nchan-running').html(" =_('In Progress')?>");
nchanStart(nchanByType[t.type]);
}
}
@@ -265,6 +289,9 @@ function onTaskListUpdate() {
}
for (var id in taskPrev) if (!taskById(id)) delete taskPrev[id];
trayRender();
+ // let the current page react to task changes (e.g. Plugins page disables its
+ // update buttons while a plugin task is running). No-op where undefined.
+ if (typeof window.onTaskListChanged === 'function') { try { window.onTaskListChanged(); } catch(e) {} }
}
var taskChannel = new NchanSubscriber('/sub/tasks',{subscriber:'websocket', reconnectTimeout:5000});
@@ -273,38 +300,110 @@ function onTaskListUpdate() {
onTaskListUpdate();
});
+// Tray expand/collapse state (mobile only). On desktop the tray is always a
+// full vertical stack; on mobile it collapses to a single tappable card with
+// the rest peeking behind it (the iOS/Android grouped-notification pattern).
+var trayExpanded = false;
+function isMobileTray() {
+ return !!(window.matchMedia && window.matchMedia('(max-width: 767px)').matches);
+}
+function expandTray() { trayExpanded = true; trayRender(); }
+function collapseTray() { trayExpanded = false; trayRender(); }
+
// render the task tray
function trayRender() {
var $tray = $('#opTray');
if (!$tray.length) return;
- if (!taskList.length) { $tray.hide().empty(); return; }
- var rows = '', finished = 0;
- for (var i=0;i=0;i--) {
+ var t = taskList[i], icon, actions='', safeId = escapeTaskHtml(t.id);
+ var top = (i==taskList.length-1) ? ' op-top' : '';
if (t.status=='done' || t.status=='error') finished++;
- var show = "\">";
+ var show = "\">";
if (t.status=='running') {
icon = "";
- actions = show + "\">";
+ actions = show + "\">";
} else if (t.status=='queued') {
icon = "";
- actions = "\">";
+ actions = "\">";
} else if (t.status=='done') {
icon = "";
- actions = show + "\">";
+ actions = show + "\">";
} else {
icon = "";
- actions = show + "\">";
+ actions = show + "\">";
}
- rows += "
"+icon+""+escapeTaskHtml(t.title)+""+actions+"
";
+ rows += "
"+icon+""+escapeTaskHtml(t.title)+""+actions+"
";
}
- // header with a bulk "Clear finished" action, shown only when there is more
- // than one finished task to clear (a lone one is easy to dismiss directly)
- var header = '';
- if (finished > 1) {
- header = "
";
+ // Header (only when more than one task): carries the count, a bulk "Clear
+ // finished" action when there is more than one finished task, and — on the
+ // mobile expanded stack — a chevron to collapse back to the single card.
+ var head = '';
+ if (count > 1) {
+ head = "
";
+ head += ""+count+" =_('operations')?>";
+ head += "";
+ if (finished > 1) {
+ head += "\"> =_('Clear finished')?>";
+ }
+ head += "\">";
+ head += "
";
}
- $tray.html(header + rows).show();
+ // Collapsed badge: a count pill on the top card so a stacked group reads as
+ // "N operations" before it's expanded.
+ var badge = (count > 1) ? ""+count+"" : "";
+ // State classes drive the CSS: collapsed shows just the top card with the
+ // others peeking; expanded shows the full vertical list with the header.
+ var mobile = isMobileTray();
+ var collapsed = mobile && !trayExpanded && count > 1;
+ $tray.removeClass('op-collapsed op-expanded op-multi');
+ if (count > 1) $tray.addClass('op-multi');
+ $tray.addClass(collapsed ? 'op-collapsed' : 'op-expanded');
+ $tray.html(head + rows + badge).show();
+}
+
+// When the mobile tray is collapsed into a single stacked card, a tap anywhere
+// on it (other than a row action) expands the full list. Delegated so it
+// survives trayRender() re-renders.
+$(document).on('click', '#opTray.op-collapsed', function(e) {
+ if ($(e.target).closest('.op-act').length) return;
+ expandTray();
+});
+// Re-evaluate collapsed/expanded layout when crossing the mobile breakpoint so
+// the tray doesn't get stuck in a mobile-only collapsed state on resize.
+if (window.matchMedia) {
+ var trayMql = window.matchMedia('(max-width: 767px)');
+ var onTrayBreakpoint = function(){ if (!isMobileTray()) trayExpanded = false; trayRender(); };
+ if (trayMql.addEventListener) trayMql.addEventListener('change', onTrayBreakpoint);
+ else if (trayMql.addListener) trayMql.addListener(onTrayBreakpoint);
+}
+
+// minimize the foreground modal: drop the live view but leave the task running
+// in the backend (the tray keeps tracking it). Backgrounding, not aborting.
+// Fade the modal out with its .nchan styling intact, then strip the class once
+// it's gone. Removing .nchan while the modal is still visible snaps it back to
+// the default swal look for a frame (the "flash"). pointer-events:none keeps the
+// fading (now-invisible but still laid-out) modal from eating clicks; the guard
+// avoids stripping .nchan off a modal that was reopened in the meantime.
+function nchanCloseModal(doClose) {
+ $('.sweet-alert').css('pointer-events','none');
+ if (doClose && typeof swal!=='undefined' && swal.close) swal.close();
+ setTimeout(function(){
+ var $sa = $('.sweet-alert');
+ $sa.css('pointer-events','');
+ if (!foregroundTaskId) $sa.removeClass('nchan');
+ }, 350);
+}
+
+function minimizeForegroundTask() {
+ if (foregroundTaskId) { foregroundTaskId=null; foregroundType=null; }
+ stopAllTypeChannels();
+ clearProgressDots();
+ nchanCloseModal(true);
+ trayRender();
}
function cancelTask(id) { $.post(TASK_ENDPOINT,{action:'abort',id:id}); }
diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
index 8b336954f3..37e5569d31 100644
--- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
+++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
@@ -201,7 +201,10 @@ function createTask(type,cmd,title,plg,func,start,button) {
if (typeof trayRender==='function') trayRender();
}
foregroundTask(res.id);
- },'json');
+ },'json').fail(function() {
+ $('div.spinner.fixed').hide();
+ swal({title:"=_('Error')?>",text:"=_('Failed to start background operation')?>",type:'error',html:true,animation:'none'});
+ });
}
// abortOperation(pid) retained for backward compatibility: map the pid to its
@@ -245,14 +248,18 @@ function openAlert(cmd,title,func) {
function openDone(data) {
if (data == '_DONE_') {
$('div.spinner.fixed').hide();
- $('button.confirm').text("=_('Done')?>").prop('disabled',false).show();
+ // task finished: corner stays minimize (keeps the finished tile in the tray);
+ // the primary Dismiss button is what removes it
+ $('.sweet-alert .nchan-close').attr('title',"=_('Minimize - keeps it in the tray')?>");
+ $('.sweet-alert').attr('data-has-confirm-button','true'); // un-hide the footer (CSS keys off this)
+ $('button.confirm').text("=_('Dismiss')?>").prop('disabled',false).show();
if (typeof ca_done_override !== 'undefined') {
if (ca_done_override == true) {
$("button.confirm").trigger("click");
ca_done_override = false;
}
}
- $('#pluginProgressTitle').text("=_('Finished');?>");
+ $('#pluginProgressTitle').attr('class','nchan-state nchan-done').html(" =_('Finished')?>");
return true;
}
return false;
@@ -261,8 +268,10 @@ function openDone(data) {
function openError(data) {
if (data == '_ERROR_') {
$('div.spinner.fixed').hide();
- $('button.confirm').text("=_('Error')?>").prop('disabled',false).show();
- $('#pluginProgressTitle').text("=_('Error');?>");
+ $('.sweet-alert .nchan-close').attr('title',"=_('Minimize - keeps it in the tray')?>");
+ $('.sweet-alert').attr('data-has-confirm-button','true'); // un-hide the footer (CSS keys off this)
+ $('button.confirm').text("=_('Dismiss')?>").prop('disabled',false).show();
+ $('#pluginProgressTitle').attr('class','nchan-state nchan-error').html(" =_('Error')?>");
return true;
}
return false;
diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php
index 8ab303c5bb..378dd88d09 100644
--- a/emhttp/plugins/dynamix/include/TaskCommand.php
+++ b/emhttp/plugins/dynamix/include/TaskCommand.php
@@ -35,7 +35,7 @@
case 'abort':
$task = task_read($id);
if ($task) {
- if ($task['status']==='running' && $task['pid'] > 1) {
+ if ($task['status']==='running' && ctype_digit((string)$task['pid']) && (int)$task['pid'] > 1) {
exec('kill '.escapeshellarg($task['pid']));
foreach (glob('/tmp/plugins/pluginPending/*') ?: [] as $file) @unlink($file);
$task['status'] = 'error';
diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php
index 3ee760ebc9..4cfba97ac2 100644
--- a/emhttp/plugins/dynamix/include/TaskQueue.php
+++ b/emhttp/plugins/dynamix/include/TaskQueue.php
@@ -120,7 +120,11 @@ function task_launch(&$task) {
// plugin scripts publish to nchan only when their last argument is 'nchan'
$suffix = $task['type']==='plugins' ? ' nchan' : '';
$env = 'NCHAN_TASK='.escapeshellarg($task['id']).' ';
- $pid = exec($env."nohup bash -c 'sleep .3 && $name $args$suffix' 1>/dev/null 2>&1 & echo \$!");
+ // escapeshellarg the whole bash -c payload so a single quote (or other shell
+ // metacharacter) in the resolved args cannot break out of the outer shell;
+ // bash still word-splits the args internally, preserving multi-arg commands.
+ $payload = 'sleep .3 && '.$name.' '.$args.$suffix;
+ $pid = exec($env.'nohup bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 & echo $!');
$task['pid'] = $pid;
$task['status'] = 'running';
$task['started'] = time();
@@ -147,11 +151,19 @@ function task_daemon_start() {
// create (and possibly immediately start) a task; returns the task record
function task_create($type,$cmd,$title,$plg,$func,$start,$button) {
if (!in_array($type, TASK_TYPES)) return null;
+ // Serialize the dedupe -> create -> launch sequence per type. Without this,
+ // two concurrent requests (e.g. a double click) could both pass the dedupe
+ // and task_running_type() checks and each call task_launch(), violating the
+ // "at most one running task per type" invariant the whole design relies on.
+ $lock = fopen(task_dir()."/.$type.lock", 'c');
+ if ($lock) flock($lock, LOCK_EX);
// dedupe: unless unconditional (start==1), don't queue an identical pending/running op
if ((int)$start !== 1) {
foreach (task_list() as $t) {
- if ($t['type']===$type && $t['cmd']===$cmd && in_array($t['status'],['queued','running']))
+ if ($t['type']===$type && $t['cmd']===$cmd && in_array($t['status'],['queued','running'])) {
+ if ($lock) { flock($lock, LOCK_UN); fclose($lock); }
return $t;
+ }
}
}
$task = [
@@ -171,6 +183,7 @@ function task_create($type,$cmd,$title,$plg,$func,$start,$button) {
];
task_write($task);
if (!task_running_type($type)) task_launch($task);
+ if ($lock) { flock($lock, LOCK_UN); fclose($lock); }
task_daemon_start();
task_publish();
return task_read($task['id']) ?: $task;
diff --git a/emhttp/plugins/dynamix/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks
index e963cbd6b4..5b2cc265e3 100755
--- a/emhttp/plugins/dynamix/nchan/tasks
+++ b/emhttp/plugins/dynamix/nchan/tasks
@@ -24,16 +24,26 @@
$docroot = '/usr/local/emhttp';
require_once "$docroot/plugins/dynamix/include/TaskQueue.php";
-// a task's stored pid is alive while /proc/ exists
+// a task's stored pid is alive while /proc/ exists; require a numeric pid so
+// an empty/garbage value can't match the /proc directory itself and wedge a task
function task_pid_alive($pid) {
- return $pid && file_exists("/proc/$pid");
+ return ctype_digit((string)$pid) && file_exists("/proc/$pid");
}
-// the operation reported a failure if its captured output contains the _ERROR_ marker
+// the operation reported a failure if its captured output contains the _ERROR_
+// control record. The log is RS(\x1e)-delimited and _DONE_/_ERROR_ are discrete
+// records (see publish.php), so match _ERROR_ as a whole record the same way the
+// live channel does (routeMessage) — a log line that merely contains the text
+// must not trip a false failure.
function task_log_has_error($id) {
$log = task_log($id);
if (!is_file($log)) return false;
- return strpos((string)@exec('tail -c 65536 '.escapeshellarg($log)), '_ERROR_') !== false;
+ $fh = @fopen($log, 'rb');
+ if (!$fh) return false;
+ if (filesize($log) > 65536) fseek($fh, -65536, SEEK_END);
+ $tail = stream_get_contents($fh);
+ fclose($fh);
+ return in_array('_ERROR_', explode("\x1e", (string)$tail), true);
}
// tidy up stale finished tasks, then publish the current state for any client
@@ -54,6 +64,17 @@ while (true) {
}
}
+ // also launch any queued type that has nothing running. Covers a daemon
+ // (re)started by the page-load nchan sweep with queued-but-not-running
+ // records: without this it would treat those as "active" below and sleep
+ // forever without ever advancing the queue.
+ foreach (task_list() as $t) {
+ if ($t['status']==='queued' && !task_running_type($t['type'])) {
+ $freed[$t['type']] = true;
+ $changed = true;
+ }
+ }
+
// start the next queued op for every type that just freed up
foreach (array_keys($freed) as $type) task_advance($type);
diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css
index 14114d7795..666b01dfde 100755
--- a/emhttp/plugins/dynamix/styles/default-base.css
+++ b/emhttp/plugins/dynamix/styles/default-base.css
@@ -1897,7 +1897,7 @@ label.checkbox input:disabled ~ .checkmark {
display: flex;
flex-direction: column;
gap: .25rem;
- z-index: 999;
+ z-index: 1000;
}
/* Lift the tray above the fixed footer so it isn't clipped behind it.
Mirrors the media queries that make #footer position:fixed. */
@@ -1909,8 +1909,22 @@ label.checkbox input:disabled ~ .checkmark {
}
.op-tray .op-tray-head {
display: flex;
- justify-content: flex-end;
- padding: 0 .25rem .1rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: .5rem;
+ padding: 0 .35rem .15rem;
+}
+.op-tray .op-tray-count {
+ font-size: 1.05rem;
+ color: var(--text-color);
+ opacity: .6;
+ text-transform: uppercase;
+ letter-spacing: .03em;
+}
+.op-tray .op-tray-head-acts {
+ display: flex;
+ align-items: center;
+ gap: .65rem;
}
.op-tray .op-tray-head .op-act {
font-size: 1.1rem;
@@ -1920,6 +1934,10 @@ label.checkbox input:disabled ~ .checkmark {
text-decoration: none;
}
.op-tray .op-tray-head .op-act:hover { opacity: 1; color: var(--brand-orange); }
+/* The collapse chevron and the count badge are part of the mobile stacked-card
+ interaction; desktop always shows the full vertical list, so hide them there. */
+.op-tray .op-tray-head .op-collapse { display: none; }
+.op-tray .op-stack-badge { display: none; }
.op-tray .op-task {
display: flex;
align-items: center;
@@ -1939,6 +1957,7 @@ label.checkbox input:disabled ~ .checkmark {
.op-tray .op-icon { flex: 0 0 auto; }
.op-tray .op-title {
flex: 1 1 auto;
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1954,12 +1973,279 @@ label.checkbox input:disabled ~ .checkmark {
text-decoration: none;
}
.op-tray .op-act:hover { color: var(--brand-orange); }
+/* The foreground task modal: a compact, centered card (a transient progress log
+ shouldn't take over the screen, so it is NOT a full-height sidebar). The base
+ swal keeps the centering + rounded corners; here we just shrink it and lay it
+ out as header / scrolling log / footer. All colors are theme tokens so it
+ adapts to white / azure / black / gray:
+ surface = --dynamix-sweet-alert-icon-bg-color text = --dynamix-sweet-alert-text-color
+ muted = --alt-text-color card = --shade-bg-color lines = --border-color
+ accent = --brand-orange. Mobile goes fullscreen (below). */
+.sweet-alert.nchan {
+ display: flex !important;
+ flex-direction: column;
+ width: 90vw;
+ max-width: 60rem;
+ height: 75vh; /* fixed (overrides swal's @media 95vh) so the log scrolls in place and the Dismiss button never moves; scales with the viewport */
+ max-height: 85vh;
+ margin: 0;
+ padding: 0;
+ text-align: left;
+ overflow: hidden;
+ box-sizing: border-box;
+ box-shadow: 0 0.5rem 2rem var(--bg-opacity-30);
+}
+/* SweetAlert adds 40px bottom padding when no buttons show (our running state);
+ the sheet manages its own padding, so cancel it. Matches swal's selector plus
+ .nchan to outspecify it. */
+.sweet-alert.nchan[data-has-confirm-button=false][data-has-cancel-button=false] {
+ padding-bottom: 0;
+}
+/* While running there is no Dismiss button, so hide the whole footer bar
+ (otherwise its border-top + padding render as an empty strip). */
+.sweet-alert.nchan[data-has-confirm-button=false] .sa-button-container {
+ display: none;
+}
+.sweet-alert.nchan > h2 {
+ flex: 0 0 auto;
+ margin: 0;
+ padding: 1.1rem 3.6rem 0.85rem 1.2rem;
+ font-size: 1.6rem;
+ line-height: 1.35;
+ font-weight: 600;
+ text-align: left;
+}
+/* status state strip below the title: a subtle theme-adaptive tint + a colored
+ icon, with normal (theme) text. Tints are translucent so they read over the
+ modal surface in every theme without the dated pale-banner look. */
+.sweet-alert.nchan .nchan-state {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+ padding: 0.5rem 1.2rem;
+ font-size: 1.1rem;
+ font-weight: 500;
+ color: var(--text-color);
+ border-bottom: 1px solid var(--border-color);
+}
+.sweet-alert.nchan .nchan-state i { font-size: 1.05em; }
+.sweet-alert.nchan .nchan-state.nchan-running { background: rgba(59,130,196,.14); }
+.sweet-alert.nchan .nchan-state.nchan-running i { color: #3b82c4; }
+.sweet-alert.nchan .nchan-state.nchan-done { background: rgba(58,156,71,.16); }
+.sweet-alert.nchan .nchan-state.nchan-done i { color: #3a9c47; }
+.sweet-alert.nchan .nchan-state.nchan-error { background: rgba(226,75,74,.14); }
+.sweet-alert.nchan .nchan-state.nchan-error i { color: #e24b4a; }
+.sweet-alert.nchan > p {
+ flex: 1 1 auto; /* fill the fixed height so the footer stays pinned to the bottom */
+ min-height: 0;
+ margin: 0;
+ padding: 1rem 1.2rem;
+ display: flex;
+ flex-direction: column;
+ text-align: left;
+}
+.sweet-alert.nchan #swaltext {
+ flex: 1 1 auto;
+ min-height: 0;
+ max-height: none;
+ overflow: auto;
+ margin: 0;
+ padding: 0;
+ text-align: left;
+ font-size: 1.2rem;
+}
+/* Slim footer with a full-width Dismiss. A single centered button looked lonely
+ and the default container (margin-top + centered flex + a hidden loader) ate
+ vertical space. */
+.sweet-alert.nchan .sa-button-container {
+ flex: 0 0 auto;
+ display: flex;
+ justify-content: flex-end;
+ margin: 0;
+ padding: 0.7rem 1.2rem;
+ border-top: 1px solid var(--border-color);
+}
+.sweet-alert.nchan .sa-confirm-button-container { display: flex; justify-content: flex-end; }
+.sweet-alert.nchan .la-ball-fall { display: none; }
+/* !important overrides SweetAlert's inline background-color:transparent / sizing */
+.sweet-alert.nchan button.confirm {
+ display: inline-block;
+ width: auto;
+ margin: 0;
+ padding: 0.5rem 1.4rem;
+ font-size: 1.25rem;
+ color: var(--white) !important;
+ background: var(--brand-orange) !important;
+ border: 0 !important;
+ border-radius: 0.4rem;
+ box-shadow: none !important;
+}
+.sweet-alert.nchan button.confirm:hover { background: var(--orange-800) !important; }
+/* top-corner minimize (running) / close (finished) control — theme-token colors
+ so it stays visible on the modal surface in every theme */
+.sweet-alert .nchan-close {
+ position: absolute;
+ top: 0.85rem;
+ right: 0.9rem;
+ width: 2.4rem;
+ height: 2.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-size: 1.3rem;
+ line-height: 1;
+ color: var(--dynamix-sweet-alert-text-color);
+ background: transparent;
+ border: 1px solid var(--border-color);
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color .15s, border-color .15s, color .15s;
+ z-index: 2;
+}
+.sweet-alert .nchan-close:hover {
+ color: var(--white);
+ background: var(--brand-orange);
+ border-color: var(--brand-orange);
+}
+/* Mobile: fullscreen (a centered card is cramped on phones). Overrides the base
+ swal centering to fill the viewport. */
+@media (max-width: 767px) {
+ .sweet-alert.nchan {
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ transform: none;
+ -webkit-transform: none;
+ width: 100vw;
+ max-width: 100vw;
+ height: 100dvh;
+ max-height: 100dvh;
+ border: 0;
+ border-radius: 0;
+ }
+ .sweet-alert.nchan .nchan-close { width: 2.9rem; height: 2.9rem; font-size: 1.6rem; }
+}
.back_to_top {
right: 40px;
}
.move_to_end {
right:12px;
}
+/* The scroll-to-top/bottom buttons share the bottom-right corner with the task
+ tray. When the tray has tasks (it is an earlier sibling of the buttons, see
+ MiscElementsBottom), move the buttons clear of it: to the bottom-left on
+ desktop. */
+.op-tray:not(:empty) ~ .back_to_top { right: auto; left: 40px; }
+.op-tray:not(:empty) ~ .move_to_end { right: auto; left: 12px; }
+/* Mobile: the tray is a grouped-notification stack (iOS/Android pattern). When
+ there are multiple tasks it collapses to the newest card with the rest peeking
+ behind it; a tap expands the full vertical list so all of them can be viewed
+ and cleared at once. The scroll buttons lift above it so they never overlap. */
+@media (max-width: 767px) {
+ .op-tray {
+ left: 1rem;
+ right: 1rem;
+ width: auto;
+ max-width: none;
+ gap: .5rem;
+ }
+ /* larger tap targets on touch (applies in both collapsed and expanded) */
+ .op-tray .op-task { padding: .85rem 1rem; min-height: 3.4rem; font-size: 1.45rem; gap: .75rem; }
+ .op-tray .op-actions { gap: .25rem; }
+ .op-tray .op-act {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 3rem;
+ min-height: 3rem;
+ font-size: 1.6rem;
+ }
+
+ /* ---- Collapsed: a single tappable card with stacked edges peeking under ---- */
+ .op-tray.op-collapsed {
+ display: block;
+ position: fixed;
+ max-height: none;
+ overflow: visible;
+ /* room under the front card for the two peeking stack edges + the badge */
+ padding-bottom: .9rem;
+ cursor: pointer;
+ }
+ .op-tray.op-collapsed .op-tray-head { display: none; }
+ .op-tray.op-collapsed .op-task { display: none; }
+ .op-tray.op-collapsed .op-task.op-top {
+ display: flex;
+ position: relative;
+ z-index: 2;
+ margin: 0;
+ }
+ /* collapsed = whole card means "expand"; its row actions are reachable once
+ expanded, so suppress them here to keep the tap target unambiguous */
+ .op-tray.op-collapsed .op-task.op-top .op-actions { display: none; }
+ /* two faux card edges fanned out below the front card to signal a stack */
+ .op-tray.op-collapsed.op-multi .op-task.op-top::before,
+ .op-tray.op-collapsed.op-multi .op-task.op-top::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ height: 1rem;
+ border: 1px solid var(--border-color);
+ border-top: 0;
+ border-bottom-left-radius: .5rem;
+ border-bottom-right-radius: .5rem;
+ background-color: var(--background-color);
+ z-index: -1;
+ }
+ .op-tray.op-collapsed.op-multi .op-task.op-top::after {
+ bottom: -.45rem;
+ width: 92%;
+ opacity: .8;
+ }
+ .op-tray.op-collapsed.op-multi .op-task.op-top::before {
+ bottom: -.9rem;
+ width: 84%;
+ opacity: .55;
+ }
+ /* count pill on the front card */
+ .op-tray.op-collapsed .op-stack-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ top: -.7rem;
+ right: -.4rem;
+ z-index: 3;
+ min-width: 1.6rem;
+ height: 1.6rem;
+ padding: 0 .45rem;
+ border-radius: .8rem;
+ background-color: var(--brand-orange);
+ color: #fff;
+ font-size: 1.05rem;
+ font-weight: 700;
+ box-shadow: 0 1px 3px rgba(0,0,0,.35);
+ cursor: pointer;
+ }
+
+ /* ---- Expanded: full vertical list, panned up from the bottom ---- */
+ .op-tray.op-expanded {
+ display: flex;
+ flex-direction: column;
+ max-height: 70vh;
+ overflow-y: auto;
+ }
+ .op-tray.op-expanded .op-tray-head .op-collapse { display: inline-flex; }
+
+ /* lift the scroll buttons clear of the taller mobile tray (front card +
+ peeking stack edges + count badge sit ~5.5rem up from the bottom) */
+ .op-tray:not(:empty) ~ .back_to_top { left: auto; right: 40px; bottom: 7.5rem; }
+ .op-tray:not(:empty) ~ .move_to_end { left: auto; right: 12px; bottom: 7.5rem; }
+}
span.big.blue-text {
cursor: pointer;
}