re-subscribes -> sees the same done task as
+ // "new" -> reloads again, forever (e.g. an OS-update task stuck as
+ // done with func:'refresh' == infinite reload / flashing UI). The
+ // finished task still shows in the tray for the user to dismiss.
+ fireTaskCallback(t);
+ }
+ } else if ((t.status=='running' || t.status=='aborting') && foregroundTaskId==t.id) {
+ var activeText = t.status=='aborting' ? "=_('Aborting')?>" : "=_('In Progress')?>";
+ $('#pluginProgressTitle').attr('class','nchan-state nchan-running').html(" "+activeText);
+ startTaskChannel(t.id);
}
+ }
+ taskPrev[t.id] = t.status;
+ }
+ 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});
+taskChannel.on('message', function(msg){
+ try { taskList = JSON.parse(msg) || []; } catch(e) { taskList = []; }
+ 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(); trayExpanded = false; return; }
+ var rows = '', finished = 0, count = taskList.length;
+ // newest first: the most recent task sits at the top of the stack and is the
+ // single card shown when the mobile tray is collapsed.
+ for (var i=taskList.length-1;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 = "\">";
+ if (t.status=='running') {
+ icon = "";
+ actions = show + "\">";
+ } else if (t.status=='aborting') {
+ icon = "\">";
+ actions = show;
+ } else if (t.status=='queued') {
+ icon = "";
+ actions = "\">";
+ } else if (t.status=='done') {
+ icon = "";
+ actions = show + "\">";
} else {
- var rows_content = rows.getElementsByClassName('content');
- if (!rows_content.length || rows_content[rows_content.length-1].textContent != data[2]) {
- rows.innerHTML += ''+data[2]+'.';
- }
+ icon = "";
+ actions = show + "\">";
}
- break;
- case 'show_Wait':
- progress_span[data[1]] = document.getElementById('wait-'+data[1]);
- progress_dots[data[1]] = setInterval(function(){if (((progress_span[data[1]].innerHTML += '.').match(/\./g)||[]).length > 9) progress_span[data[1]].innerHTML = progress_span[data[1]].innerHTML.replace(/\.+$/,'');},500);
- break;
- case 'stop_Wait':
- clearInterval(progress_dots[data[1]]);
- progress_span[data[1]].innerHTML = '';
- break;
- default:
- box.html(box.html()+data[0]);
- break;
+ rows += ""+icon+""+escapeTaskHtml(t.title)+""+actions+"
";
}
- box.scrollTop(box[0].scrollHeight);
+ // 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 += " ";
+ }
+ // 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 clearNchanChrome($sa) {
+ $sa = $sa || $('.sweet-alert');
+ $sa.removeClass('nchan').css('pointer-events','');
+ $sa.children('.nchan-state,.nchan-close,.nchan-resize').remove();
+}
+
+function finishTaskModalClose(token) {
+ if (!taskModalClosing || token !== taskModalCloseToken) return;
+ if (taskModalCloseTimer) clearTimeout(taskModalCloseTimer);
+ taskModalCloseTimer = null;
+ taskModalClosing = false;
+ clearNchanChrome($('.sweet-alert'));
+ var queued = taskModalCloseQueue;
+ taskModalCloseQueue = [];
+ for (var i=0;i",text:"=_('This may leave an unknown state')?>",html:true,animation:'none',type:'warning',showCancelButton:true,confirmButtonText:"=_('Proceed')?>",cancelButtonText:"=_('Cancel')?>"},function(){
+ $.post(TASK_ENDPOINT,{action:'abort',id:id});
+ });
+ };
+ if (taskModalClosing) taskModalCloseQueue.push(show);
+ else show();
+}
const scrollDuration = 500;
$(window).scroll(function() {
@@ -259,7 +694,8 @@ function wlanSettings() {
$('html, body').scrollTop(top);
}
$.removeCookie('top');
- if ($.cookie('addAlert') != null) bannerAlert(addAlert.text,addAlert.cmd,addAlert.plg,addAlert.func);
+ // subscribe to the shared task list; the tray renders from server state
+ taskChannel.start();
showNotice("=_('System running in')?> =('safe mode')?>");
diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
index f8b697a55c..5ee628f1c1 100644
--- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
+++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
@@ -28,12 +28,6 @@
// tty window
var tty_window = null;
-const addAlert = {};
-addAlert.text = $.cookie('addAlert-text');
-addAlert.cmd = $.cookie('addAlert-cmd');
-addAlert.plg = $.cookie('addAlert-plg');
-addAlert.func = $.cookie('addAlert-func');
-
// current csrf_token
var csrf_token = "=_var($var,'csrf_token')?>";
@@ -170,126 +164,61 @@ function openTerminal(tag,name,more) {
$.get('/webGui/include/OpenTerminal.php',{tag:tag,name:name,more:more},function(){setTimeout(function(){tty_window.location=socket; tty_window.focus();},200);});
}
-function bannerAlert(text,cmd,plg,func,start) {
- $.post('/webGui/include/StartCommand.php',{cmd:cmd,pid:1},function(pid) {
- if (pid == 0) {
- if ($(".upgrade_notice").hasClass('done') || timers.bannerAlert == null) {
- forcedBanner = false;
- if ($.cookie('addAlert') != null) {
- removeBannerWarning($.cookie('addAlert'));
- $.removeCookie('addAlert');
- }
- $(".upgrade_notice").removeClass('alert done');
- timers.callback = null;
- if (plg != null) {
- if ($.cookie('addAlert-page') == null || $.cookie('addAlert-page') == '=$task?>') {
- setTimeout((func||'loadlist')+'("'+plg+'")',250);
- } else if ('Plugins' == '=$task?>') {
- setTimeout(refresh);
- }
- }
- $.removeCookie('addAlert-page');
- } else {
- $(".upgrade_notice").removeClass('alert').addClass('done');
- timers.bannerAlert = null;
- setTimeout(function(){bannerAlert(text,cmd,plg,func,start);},1000);
- }
- } else {
- $.cookie('addAlert',addBannerWarning(text,true,true,true));
- $.cookie('addAlert-text',text);
- $.cookie('addAlert-cmd',cmd);
- $.cookie('addAlert-plg',plg);
- $.cookie('addAlert-func',func);
- if ($.cookie('addAlert-page') == null) $.cookie('addAlert-page','=$task?>');
- timers.bannerAlert = setTimeout(function(){bannerAlert(text,cmd,plg,func,start);},1000);
- if (start==1 && timers.callback==null && plg!=null) timers.callback = setTimeout((func||'loadlist')+'("'+plg+'")',250);
- }
- });
-}
-
-function openPlugin(cmd,title,plg,func,start=0,button=0) {
- // start = 0 : run command only when not already running (default)
- // start = 1 : run command unconditionally
- // button = 0 : show CLOSE button (default)
- // button = 1 : hide CLOSE button
- nchan_plugins.start();
- $.post('/webGui/include/StartCommand.php',{cmd:cmd+' nchan',start:start},function(pid) {
- if (pid==0) {
- nchan_plugins.stop();
- $('div.spinner.fixed').hide();
- $(".upgrade_notice").addClass('alert');
- return;
- }
- swal({title:title + ' - =_('In Progress');?> ',text:"
",html:true,animation:'none',showConfirmButton:button==0,confirmButtonText:"=_('Close')?>"},function(close){
- nchan_plugins.stop();
- $('div.spinner.fixed').hide();
- $('.sweet-alert').hide('fast').removeClass('nchan');
- setTimeout(function(){bannerAlert("=_('Attention - operation continues in background')?> ["+pid.toString().padStart(8,'0')+"]\" onclick='abortOperation("+pid+")'>",cmd,plg,func,start);});
- });
- $('.sweet-alert').addClass('nchan');
- $('button.confirm').prop('disabled',button!=0);
- });
-}
-
-function openDocker(cmd,title,plg,func,start=0,button=0) {
- // start = 0 : run command only when not already running (default)
- // start = 1 : run command unconditionally
- // button = 0 : hide CLOSE button (default)
- // button = 1 : show CLOSE button
- nchan_docker.start();
- $.post('/webGui/include/StartCommand.php',{cmd:cmd,start:start},function(pid) {
- if (pid==0) {
- nchan_docker.stop();
- $('div.spinner.fixed').hide();
- $(".upgrade_notice").addClass('alert');
- return;
- }
- swal({title:title + ' - =_('In Progress');?> ',text:"
",html:true,animation:'none',showConfirmButton:button!=0,confirmButtonText:"=_('Close')?>"},function(close){
- nchan_docker.stop();
- $('div.spinner.fixed').hide();
- $('.sweet-alert').hide('fast').removeClass('nchan');
- setTimeout(function(){bannerAlert("=_('Attention - operation continues in background')?> ["+pid.toString().padStart(8,'0')+"]\" onclick='abortOperation("+pid+")'>",cmd,plg,func,start);});
- });
- $('.sweet-alert').addClass('nchan');
- $('button.confirm').prop('disabled',button==0);
- });
-}
-
-function openVMAction(cmd,title,plg,func,start=0,button=0) {
- // start = 0 : run command only when not already running (default)
- // start = 1 : run command unconditionally
- // button = 0 : hide CLOSE button (default)
- // button = 1 : show CLOSE button
- nchan_vmaction.start();
- $.post('/webGui/include/StartCommand.php',{cmd:cmd,start:start},function(pid) {
- if (pid==0) {
- nchan_vmaction.stop();
- $('div.spinner.fixed').hide();
- $(".upgrade_notice").addClass('alert');
- return;
+// Retired: backgrounded operations are now tracked by the shared task tray
+// (see the /sub/tasks subscriber and trayRender in BodyInlineJS). Kept as a
+// no-op stub in case any external plugin still references it.
+function bannerAlert() {}
+
+// openPlugin/openDocker/openVMAction keep their original signatures for all
+// external callers, but now enqueue a backend-tracked task (TaskQueue.php) and
+// bring it to the foreground. The backend serializes one running op per type
+// and the task tray lets any client re-open or background it.
+// start = 0 : run command only when not already running (default)
+// start = 1 : run command unconditionally
+// button : retained in the signature/record for caller compatibility; the
+// shared task sheet now derives its controls from task status
+function openPlugin(cmd,title,plg,func,start=0,button=0) { createTask('plugins', cmd,title,plg,func,start,button); }
+function openDocker(cmd,title,plg,func,start=0,button=0) { createTask('docker', cmd,title,plg,func,start,button); }
+function openVMAction(cmd,title,plg,func,start=0,button=0) { createTask('vmaction',cmd,title,plg,func,start,button); }
+
+function createTask(type,cmd,title,plg,func,start,button) {
+ $.post('/plugins/dynamix/include/TaskCommand.php',{
+ action:'create', type:type,
+ cmd:encodeURIComponent(cmd), title:encodeURIComponent(title),
+ plg:plg||'', func:func||'', start:start||0, button:button||0
+ },function(res) {
+ $('div.spinner.fixed').hide();
+ if (!res || !res.id) return;
+ // Completion callbacks belong to tabs that initiated/joined the operation,
+ // not every tab that happens to observe the shared task-list transition.
+ taskCallbackOwned[res.id] = true;
+ // The authoritative task list is broadcast asynchronously on /sub/tasks and
+ // can land *after* this AJAX response. Without the entry in taskList,
+ // foregroundTask() can't find the task and bails, so the modal never opens
+ // even though the command runs (and logs) in the background. Seed an
+ // optimistic entry from what we already know; onTaskListUpdate() reconciles
+ // it when the real broadcast arrives.
+ if (typeof taskById==='function' && !taskById(res.id)) {
+ taskList.push({id:res.id,type:type,title:title,cmd:cmd,plg:plg||'',func:func||'',
+ start:start||0,button:button||0,pid:'',status:res.status||'running',
+ created:0,started:0,finished:0});
+ taskPrev[res.id] = res.status||'running';
+ if (typeof trayRender==='function') trayRender();
}
- swal({title:title + ' - =_('In Progress');?> ',text:"
",html:true,animation:'none',showConfirmButton:button!=0,confirmButtonText:"=_('Close')?>"},function(close){
- nchan_vmaction.stop();
- $('div.spinner.fixed').hide();
- $('.sweet-alert').hide('fast').removeClass('nchan');
- setTimeout(function(){bannerAlert("=_('Attention - operation continues in background')?> ["+pid.toString().padStart(8,'0')+"]\" onclick='abortOperation("+pid+")'>",cmd,plg,func,start);});
- });
- $('.sweet-alert').addClass('nchan');
- $('button.confirm').prop('disabled',button==0);
+ foregroundTask(res.id);
+ },'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
+// task and abort it through the queue (falls back to a direct kill if unknown).
function abortOperation(pid) {
+ var t = (typeof taskByPid==='function') ? taskByPid(pid) : null;
+ if (t) { confirmAbortTask(t.id); return; }
swal({title:"=_('Abort background operation')?>",text:"=_('This may leave an unknown state')?>",html:true,animation:'none',type:'warning',showCancelButton:true,confirmButtonText:"=_('Proceed')?>",cancelButtonText:"=_('Cancel')?>"},function(){
- $.post('/webGui/include/StartCommand.php',{kill:pid},function() {
- clearTimeout(timers.bannerAlert);
- timers.bannerAlert = null;
- timers.callback = null;
- forcedBanner = false;
- removeBannerWarning($.cookie('addAlert'));
- $.removeCookie('addAlert');
- $(".upgrade_notice").removeClass('alert done').hide();
- });
+ $.post('/webGui/include/StartCommand.php',{kill:pid});
});
}
@@ -305,6 +234,7 @@ function openChanges(cmd,title,nchan,button=0) {
if ($('#submit_button').length > 0) $('#submit_button').remove();
});
$('.sweet-alert').addClass('nchan');
+ if (typeof decorateNchanSheet === 'function') decorateNchanSheet(); // shared close (x) + resize grip
$('pre#swalbody').html(data);
$('button.confirm').text("=_('Done')?>").prop('disabled',false).show();
});
@@ -317,6 +247,7 @@ function openAlert(cmd,title,func) {
if (proceed) setTimeout(func+'()');
});
$('.sweet-alert').addClass('nchan');
+ if (typeof decorateNchanSheet === 'function') decorateNchanSheet(); // shared close (x) + resize grip
$('pre#swalbody').html(data);
});
}
@@ -324,14 +255,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;
@@ -340,8 +275,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/DefaultPageLayout/MiscElementsBottom.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/MiscElementsBottom.php
index 099e87f506..9d023f98ea 100644
--- a/emhttp/plugins/dynamix/include/DefaultPageLayout/MiscElementsBottom.php
+++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/MiscElementsBottom.php
@@ -14,6 +14,8 @@
+
+
diff --git a/emhttp/plugins/dynamix/include/TaskCapture.php b/emhttp/plugins/dynamix/include/TaskCapture.php
new file mode 100644
index 0000000000..a047ed4eb0
--- /dev/null
+++ b/emhttp/plugins/dynamix/include/TaskCapture.php
@@ -0,0 +1,37 @@
+
+
+// Internal target for nchan_publisher_upstream_request in rc.nginx. Requiring
+// both the internal URI and marker prevents this script's public filesystem URL
+// from being used to inject output into a running task.
+$internal = preg_match(
+ '#^/task-capture/(plugins|docker|vmaction)$#',
+ $_SERVER['REQUEST_URI'] ?? '',
+ $match
+);
+if (!$internal || ($_SERVER['HTTP_X_TASK_CAPTURE'] ?? '') !== '1') {
+ http_response_code(404);
+ die();
+}
+
+$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
+require_once "$docroot/plugins/dynamix/include/TaskQueue.php";
+
+$type = $_GET['type'] ?? $match[1];
+$message = file_get_contents('php://input');
+if (is_string($message)) task_capture($type, $message);
+
+// Nchan interprets 304 as "publish the original message unchanged".
+http_response_code(304);
+die();
+?>
diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php
new file mode 100644
index 0000000000..67c0799834
--- /dev/null
+++ b/emhttp/plugins/dynamix/include/TaskCommand.php
@@ -0,0 +1,118 @@
+
+
+// Mutations are POST-only and CSRF-protected globally by local_prepend.php.
+$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
+require_once "$docroot/plugins/dynamix/include/TaskQueue.php";
+
+$action = $_POST['action'] ?? $_GET['action'] ?? '';
+$id = $_POST['id'] ?? $_GET['id'] ?? '';
+$mutations = ['create','abort','dismiss','clear'];
+if (in_array($action, $mutations, true) && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
+ header('Allow: POST');
+ http_response_code(405);
+ die();
+}
+
+switch ($action) {
+case 'create':
+ $task = task_create(
+ $_POST['type'] ?? '',
+ rawurldecode($_POST['cmd'] ?? ''),
+ rawurldecode($_POST['title'] ?? ''),
+ $_POST['plg'] ?? '',
+ $_POST['func'] ?? '',
+ $_POST['start'] ?? 0,
+ $_POST['button'] ?? 0
+ );
+ header('Content-Type: application/json');
+ die(json_encode($task ? ['id'=>$task['id'],'status'=>$task['status']] : ['error'=>'invalid']));
+
+case 'abort':
+ $task = task_read($id);
+ if ($task) {
+ $lock = task_type_lock($task['type']);
+ if (!$lock) { http_response_code(503); die(); }
+ $task = task_read($id);
+ if ($task && $task['status']==='running') {
+ // Keep the type slot occupied until task_complete or the daemon confirms
+ // that the owned process group has exited. Advancing immediately after an
+ // asynchronous TERM can overlap two destructive operations and attribute
+ // the old operation's trailing output to the new task.
+ $task['status'] = 'aborting';
+ $task['abort_requested'] = time();
+ if (!task_write($task)) {
+ task_type_unlock($lock);
+ http_response_code(500);
+ die();
+ }
+ task_signal_group($task, 'TERM');
+ foreach (glob('/tmp/plugins/pluginPending/*') ?: [] as $file) @unlink($file);
+ } elseif ($task && $task['status']!=='aborting') {
+ // A queued task can be removed while holding the same type lock used by
+ // advancement, so cancellation cannot race with task_launch().
+ task_delete($id);
+ }
+ task_type_unlock($lock);
+ if ($task && $task['status']==='aborting') task_daemon_start();
+ task_publish();
+ }
+ die();
+
+case 'dismiss':
+ $task = task_read($id);
+ if ($task && in_array($task['status'],['done','error'])) {
+ task_delete($id);
+ task_publish();
+ }
+ die();
+
+case 'clear':
+ // remove every finished (done/error) task at once
+ task_clear_finished();
+ task_publish();
+ die();
+
+case 'log':
+ // Output captured so far, for foreground replay. X-Task-Log-Size is the
+ // exact byte length served: live task-channel messages carry their log
+ // byte offset (see task_capture() in TaskQueue.php) and the client drops any
+ // live message whose offset falls below this, so the replay/live handoff
+ // never duplicates or loses a record. Read under the shared lock
+ // (task_capture() appends under the exclusive one) so the length always
+ // lands on a record boundary.
+ header('Content-Type: text/plain');
+ $data = '';
+ if (task_valid_id($id) && is_file(task_log($id))) {
+ $fh = @fopen(task_log($id), 'rb');
+ if ($fh) {
+ if (!flock($fh, LOCK_SH)) {
+ fclose($fh);
+ my_logger("Task log replay failed to lock $id");
+ http_response_code(503);
+ die();
+ }
+ $data = stream_get_contents($fh) ?: '';
+ flock($fh, LOCK_UN);
+ fclose($fh);
+ }
+ }
+ header('X-Task-Log-Size: '.strlen($data));
+ die($data);
+
+case 'list':
+ header('Content-Type: application/json');
+ die(json_encode(task_list()));
+}
+die();
+?>
diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php
new file mode 100644
index 0000000000..b96a47a494
--- /dev/null
+++ b/emhttp/plugins/dynamix/include/TaskQueue.php
@@ -0,0 +1,485 @@
+
+
+/**
+ * Backend task queue shared across subsystems (plugins / docker / vmaction).
+ *
+ * State lives in TASK_DIR as one .json per task plus a per-task .log
+ * capturing the operation's nchan output. Nginx sends every shared task-type
+ * publication through TaskCapture.php, including legacy scripts that POST
+ * directly to /pub/. The full task list is broadcast to all clients on
+ * the `tasks` nchan channel whenever it changes.
+ *
+ * Scheduling rule: at most one queue-owned active task per type at any time.
+ * Additional same-type operations are queued and auto-started by the `tasks`
+ * daemon when the active one finishes. Unrelated legacy processes can still
+ * publish on the shared type channels and are outside this queue invariant.
+ */
+
+$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
+require_once "$docroot/webGui/include/Helpers.php";
+require_once "$docroot/webGui/include/Wrappers.php";
+require_once "$docroot/webGui/include/publish.php";
+require_once "$docroot/webGui/include/Secure.php";
+
+define('TASK_DIR', '/var/local/emhttp/tasks');
+define('TASK_DAEMON', 'plugins/dynamix/nchan/tasks');
+define('TASK_DONE_TTL', 86400); // prune done/error tasks after 1 day
+define('TASK_ABORT_GRACE', 5); // seconds before an abort escalates TERM -> KILL
+define('TASK_TYPES', ['plugins','docker','vmaction']);
+
+// task ids are produced by uniqid() => lowercase hex; validate anything used in a path
+function task_valid_id($id) {
+ return is_string($id) && preg_match('/^[a-f0-9]+$/', $id);
+}
+
+function task_dir() {
+ if (!is_dir(TASK_DIR)) @mkdir(TASK_DIR, 0770, true);
+ return TASK_DIR;
+}
+
+function task_path($id) { return TASK_DIR."/$id.json"; }
+function task_log($id) { return TASK_DIR."/$id.log"; }
+
+function task_read($id) {
+ if (!task_valid_id($id)) return null;
+ $file = task_path($id);
+ if (!is_file($file)) return null;
+ $data = json_decode(@file_get_contents($file), true);
+ return is_array($data) ? $data : null;
+}
+
+function task_write($task) {
+ task_dir();
+ return file_put_contents_atomic(task_path($task['id']), json_encode($task));
+}
+
+function task_type_lock($type) {
+ if (!in_array($type, TASK_TYPES, true)) return false;
+ $lock = @fopen(task_dir()."/.$type.lock", 'c');
+ if (!$lock) return false;
+ if (!flock($lock, LOCK_EX)) { fclose($lock); return false; }
+ return $lock;
+}
+
+function task_type_unlock($lock) {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+}
+
+// Read the Linux process identity fields needed to distinguish a task's
+// session leader from a later process that reused the same numeric PID.
+function task_proc_stat($pid) {
+ if (!ctype_digit((string)$pid) || (int)$pid <= 1) return null;
+ $stat = @file_get_contents("/proc/$pid/stat");
+ if (!is_string($stat)) return null;
+ $end = strrpos($stat, ')');
+ if ($end === false) return null;
+ $fields = preg_split('/\s+/', trim(substr($stat, $end + 1)));
+ if (count($fields) < 20) return null;
+ return [
+ 'pid' => (int)$pid,
+ 'state' => (string)$fields[0], // proc field 3
+ 'pgrp' => (int)$fields[2], // proc field 5
+ 'session' => (int)$fields[3], // proc field 6
+ 'starttime' => (string)$fields[19], // proc field 22
+ ];
+}
+
+function task_process_identity($handshake) {
+ for ($i = 0; $i < 200; $i++) {
+ $claimed = trim((string)@file_get_contents($handshake));
+ $stat = task_proc_stat($claimed);
+ if ($stat && $stat['state']==='T' && $stat['pgrp']===(int)$claimed && $stat['session']===(int)$claimed) return $stat;
+ usleep(10000);
+ }
+ return null;
+}
+
+function task_process_group_alive($task) {
+ $pid = (int)($task['pid'] ?? 0);
+ $pgrp = (int)($task['pgrp'] ?? 0);
+ $session = (int)($task['session'] ?? 0);
+ $starttime = (string)($task['pid_start'] ?? '');
+ if ($pid <= 1 || $pgrp !== $pid || $session !== $pid || $starttime === '') return false;
+
+ // If the leader PID exists but its birth time changed, the id was reused and
+ // must never be treated as (or signalled as) this task's process group.
+ $leader = task_proc_stat($pid);
+ if ($leader && $leader['starttime'] !== $starttime) return false;
+ if ($leader && $leader['state']!=='Z' && $leader['pgrp']===$pgrp && $leader['session']===$session) return true;
+ // The leader can exit before descendants finish. In that case scan for a
+ // surviving member of the original session/process group.
+ foreach (glob('/proc/[0-9]*/stat', GLOB_NOSORT) ?: [] as $file) {
+ $member = task_proc_stat(basename(dirname($file)));
+ if ($member && $member['state']!=='Z' && $member['pgrp']===$pgrp && $member['session']===$session) return true;
+ }
+ return false;
+}
+
+function task_signal_group($task, $signal) {
+ if (!task_process_group_alive($task)) return false;
+ $pgrp = (int)$task['pgrp'];
+ exec('kill -'.$signal.' -'.$pgrp.' 2>/dev/null', $out, $rc);
+ return $rc === 0;
+}
+
+function task_wait_group_exit($task, $attempts = 100) {
+ for ($i = 0; $i < $attempts; $i++) {
+ if (!task_process_group_alive($task)) return true;
+ usleep(10000);
+ }
+ return !task_process_group_alive($task);
+}
+
+// Fail closed after an owned launcher exists but cannot be safely started.
+// Never release its type slot until the group is confirmed gone; if it survives
+// KILL, persist an aborting sentinel for the daemon to keep monitoring.
+function task_fail_launch(&$task, $handshake) {
+ task_signal_group($task, 'KILL');
+ if (task_wait_group_exit($task, 200)) {
+ $task['status'] = 'error';
+ $task['finished'] = time();
+ if (!task_write($task)) @unlink(task_path($task['id']));
+ @unlink($handshake);
+ return false;
+ }
+
+ $task['status'] = 'aborting';
+ $task['abort_requested'] = 0; // daemon escalates immediately
+ // A prior state write may have failed. Keep the caller's type lock and retry
+ // until either the sentinel is durable or the stopped group is confirmed gone.
+ while (!task_write($task)) {
+ task_signal_group($task, 'KILL');
+ if (task_wait_group_exit($task, 100)) {
+ @unlink($handshake);
+ @unlink(task_path($task['id']));
+ return false;
+ }
+ }
+ return false;
+}
+
+function task_delete($id) {
+ if (!task_valid_id($id)) return;
+ delete_file(task_path($id), task_log($id));
+ task_channel_delete($id);
+}
+
+// Drop the task's mirrored nchan channel (see task_capture()) along with the
+// task.
+// The generic /pub/ location keeps messages forever (nchan_message_timeout 0),
+// so without this every finished task would leave its retained buffer parked in
+// nchan shared memory for the life of the nginx process.
+function task_channel_delete($id) {
+ // buffer_length is required by the /pub/ location config for every method
+ // (nchan_message_buffer_length $arg_buffer_length); without it nginx errors
+ // out before nchan sees the DELETE
+ $com = curl_init("http://localhost/pub/task-$id?buffer_length=1");
+ curl_setopt_array($com, [
+ CURLOPT_UNIX_SOCKET_PATH => '/var/run/nginx.socket',
+ CURLOPT_CUSTOMREQUEST => 'DELETE',
+ CURLOPT_RETURNTRANSFER => 1,
+ ]);
+ curl_exec($com);
+ curl_close($com);
+}
+
+// all tasks, oldest first (FIFO by creation time, id breaks ties)
+function task_list() {
+ $tasks = [];
+ foreach (glob(TASK_DIR.'/*.json') ?: [] as $file) {
+ $data = json_decode(@file_get_contents($file), true);
+ if (is_array($data) && isset($data['id'])) $tasks[] = $data;
+ }
+ usort($tasks, function($a,$b) {
+ return ($a['created'] <=> $b['created']) ?: strcmp($a['id'],$b['id']);
+ });
+ return $tasks;
+}
+
+// broadcast the full list to every connected client
+function task_publish() {
+ publish('tasks', json_encode(task_list()));
+}
+
+// the single running task of a type, or null
+function task_running_type($type) {
+ foreach (task_list() as $t)
+ if ($t['type']===$type && in_array($t['status'], ['running','aborting'], true)) return $t;
+ return null;
+}
+
+// Persist and mirror one message published on a shared task-type channel.
+// Capture happens from Nginx's publisher hook instead of publish.php because
+// third-party plugin scripts commonly POST straight to /pub/plugins. The task
+// queue guarantees at most one queue-owned active task per type. A publication
+// from an unrelated process of the same type remains a known attribution limit.
+//
+// Messages are RS-delimited in the log. The task channel carries the record's
+// byte offset as "\x1f", allowing the foreground client to
+// dedupe precisely against a simultaneous log replay. The append is exclusive
+// and TaskCommand.php reads under a shared lock, so offsets always land on
+// record boundaries.
+function task_capture($type, $message) {
+ if (!in_array($type, TASK_TYPES, true)) return false;
+ $task = task_running_type($type);
+ if (!$task || !task_valid_id($task['id'])) return false;
+
+ $fh = @fopen(task_log($task['id']), 'c');
+ if (!$fh) return false;
+ if (!flock($fh, LOCK_EX)) {
+ fclose($fh);
+ my_logger("Task capture failed to lock log for {$task['id']}");
+ return false;
+ }
+ fseek($fh, 0, SEEK_END);
+ $offset = ftell($fh);
+ $record = $message."\x1e";
+ $written = $offset !== false ? fwrite($fh, $record) : false;
+ $complete = $written === strlen($record);
+ if (!$complete && $offset !== false) ftruncate($fh, $offset);
+ fflush($fh);
+ if (!$complete) {
+ flock($fh, LOCK_UN);
+ fclose($fh);
+ my_logger("Task capture failed to append log for {$task['id']}");
+ return false;
+ }
+
+ // A small retained buffer lets a foreground subscriber joining mid-stream
+ // catch up; byte offsets make any redelivery harmless. Keep the log lock
+ // through the mirror publish so concurrent writers preserve offset order.
+ publish("task-{$task['id']}", $offset."\x1f".$message, 10);
+ flock($fh, LOCK_UN);
+ fclose($fh);
+ return true;
+}
+
+// resolve a command to an absolute script path the same way StartCommand.php does
+function task_resolve($cmd) {
+ global $docroot;
+ [$command,$args] = array_pad(explode(' ', unscript($cmd), 2), 2, '');
+ $name = '';
+ $path = '';
+ foreach (glob("$docroot/plugins/*/scripts", GLOB_NOSORT) as $path) {
+ if ($name = realpath("$path/$command")) break;
+ }
+ if (!$command || !$name || strncmp($name,$path,strlen($path))!==0) return null;
+ return [$name, $args];
+}
+
+// launch a task in the background; Nginx's publisher hook captures its output
+function task_launch(&$task) {
+ global $docroot;
+ // guard: never run two of the same type at once
+ if (task_running_type($task['type'])) return false;
+ $resolved = task_resolve($task['cmd']);
+ if (!$resolved) {
+ $task['status'] = 'error';
+ $task['finished'] = time();
+ if (!task_write($task)) @unlink(task_path($task['id']));
+ return false;
+ }
+ [$name,$args] = $resolved;
+ // plugin scripts publish to nchan only when their last argument is 'nchan'
+ $suffix = $task['type']==='plugins' ? ' nchan' : '';
+ // Keep the task id available to task-aware scripts and diagnostics. Output
+ // capture itself no longer depends on this variable: TaskCapture.php handles
+ // every publication on the shared type channel, including legacy scripts.
+ $env = 'NCHAN_TASK='.escapeshellarg($task['id']).' ';
+ // The command records its own terminal state on exit: capture its exit code
+ // and hand it to task_complete, which marks the task done/error, advances the
+ // queue and broadcasts. This makes completion authoritative at the source
+ // instead of relying on the scheduler daemon to observe the PID disappear
+ // (which it can miss on PID reuse or a daemon-restart race). NCHAN_TASK is
+ // cleared before the completion helper because the operation itself is over.
+ // The daemon stays a fallback for the case where the process is hard-killed
+ // before the stamp can run.
+ $complete = "$docroot/plugins/dynamix/include/task_complete";
+ $stamp = '; rc=$?; NCHAN_TASK= '.escapeshellarg($complete).' '.escapeshellarg($task['id']).' "$rc"';
+ // 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.
+ // The wrapper writes its own PID and stops before the operation begins. PHP
+ // validates + persists that process identity, then resumes the group. This
+ // handshake means no privileged payload can run untracked and cleanup never
+ // has to signal an unverified numeric PID.
+ $handshake = task_dir().'/.launch-'.$task['id'];
+ @unlink($handshake);
+ $gate = 'printf "%s\\n" "$$" > '.escapeshellarg($handshake).' || exit 125; kill -STOP $$; rm -f '.escapeshellarg($handshake).'; ';
+ $payload = $gate.'sleep .3 && '.$name.' '.$args.$suffix.$stamp;
+ // setsid runs the operation in its own session + process group (pid == pgid),
+ // so Abort (TaskCommand.php) can signal the whole tree via a negative-pid group
+ // kill and actually stop the underlying command and every child it spawned.
+ // With a plain nohup the wrapper shell stays in php-fpm's process group: killing
+ // just its pid orphaned the real worker (e.g. a docker update), which then ran
+ // to completion after the task was already marked error.
+ exec($env.'setsid bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 &');
+ $identity = task_process_identity($handshake);
+ if (!$identity) {
+ // A matching handshake file plus a stopped process proves ownership even
+ // when setsid failed to establish the expected group. Kill only that owned
+ // launcher PID; never fall back to an unverified numeric PID or group.
+ $claimed = trim((string)@file_get_contents($handshake));
+ $stopped = task_proc_stat($claimed);
+ if ($stopped && $stopped['state']==='T') exec('kill -KILL '.(int)$claimed.' 2>/dev/null');
+ @unlink($handshake);
+ $task['status'] = 'error';
+ $task['finished'] = time();
+ if (!task_write($task)) @unlink(task_path($task['id']));
+ return false;
+ }
+ $pid = (string)$identity['pid'];
+ $task['pid'] = $pid;
+ $task['pid_start'] = $identity['starttime'];
+ $task['pgrp'] = $identity['pgrp'];
+ $task['session'] = $identity['session'];
+ $task['status'] = 'running';
+ $task['started'] = time();
+ if (!task_write($task)) return task_fail_launch($task, $handshake);
+ if (!task_signal_group($task, 'CONT')) return task_fail_launch($task, $handshake);
+ @unlink($handshake);
+ return $pid;
+}
+
+// Caller holds the per-type lock.
+function task_advance_locked($type) {
+ if (task_running_type($type)) return false;
+ foreach (task_list() as $task) {
+ if ($task['type']===$type && $task['status']==='queued') return task_launch($task);
+ }
+ return false;
+}
+
+// start the next queued task of a type if nothing of that type is running.
+// Takes the per-type lock so the check-and-launch is atomic against task_create
+// and task_complete; without it two advancers (e.g. the daemon and a task's own
+// completion stamp firing at the same instant) could both pass task_running_type
+// and double-launch the next queued task. Callers must NOT already hold the lock.
+function task_advance($type) {
+ $lock = task_type_lock($type);
+ if (!$lock) return false;
+ task_advance_locked($type);
+ task_type_unlock($lock);
+ return true;
+}
+
+// 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 task_capture()), 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;
+ $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);
+}
+
+// Record a task's terminal state from its command's own exit (invoked by the
+// wrapper in task_launch via plugins/dynamix/include/task_complete). $rc is the
+// command's exit code; a task is an error when it exited non-zero or published
+// an _ERROR_ record. Marking and selection of the next queued task are atomic
+// under the per-type lock. An already-finalized task (or one marked by the
+// daemon first) is left untouched.
+function task_complete($id, $rc) {
+ $task = task_read($id);
+ if (!$task) return;
+ $type = $task['type'];
+ $lock = task_type_lock($type);
+ if (!$lock) return false;
+ $task = task_read($id); // re-read under lock
+ $changed = false;
+ if ($task && in_array($task['status'], ['running','aborting'], true)) {
+ $task['status'] = ($task['status']==='aborting' || (int)$rc !== 0 || task_log_has_error($id)) ? 'error' : 'done';
+ $task['finished'] = time();
+ $changed = (bool)task_write($task);
+ if ($changed) task_advance_locked($type);
+ }
+ task_type_unlock($lock);
+ if ($changed) task_publish();
+ return $changed;
+}
+
+// (re)start the scheduling daemon if it isn't already running
+function task_daemon_start() {
+ global $docroot;
+ $script = "$docroot/".TASK_DAEMON;
+ exec('pgrep --ns $$ -f '.escapeshellarg($script), $out, $ret);
+ if ($ret !== 0) exec(escapeshellarg($script).' >/dev/null 2>&1 &');
+}
+
+// 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, true)) 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 = task_type_lock($type);
+ if (!$lock) return null;
+ // 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','aborting'], true)) {
+ task_type_unlock($lock);
+ return $t;
+ }
+ }
+ }
+ $task = [
+ 'id' => uniqid(),
+ 'type' => $type,
+ 'title' => $title,
+ 'cmd' => $cmd,
+ 'plg' => $plg,
+ 'func' => $func,
+ 'start' => (int)$start,
+ 'button' => (int)$button,
+ 'pid' => '',
+ 'pid_start'=> '',
+ 'pgrp' => 0,
+ 'session' => 0,
+ 'status' => 'queued',
+ 'created' => time(),
+ 'started' => 0,
+ 'finished' => 0,
+ ];
+ if (!task_write($task)) { task_type_unlock($lock); return null; }
+ task_advance_locked($type);
+ task_type_unlock($lock);
+ task_daemon_start();
+ task_publish();
+ return task_read($task['id']);
+}
+
+// remove finished tasks older than the TTL (called by the daemon on startup)
+function task_prune() {
+ $now = time();
+ foreach (task_list() as $t) {
+ if (in_array($t['status'],['done','error']) && ($now - ($t['finished'] ?: $t['created'])) > TASK_DONE_TTL)
+ task_delete($t['id']);
+ }
+}
+
+// remove every finished task now (the tray's "Clear finished" action)
+function task_clear_finished() {
+ foreach (task_list() as $t)
+ if (in_array($t['status'],['done','error'])) task_delete($t['id']);
+}
+?>
diff --git a/emhttp/plugins/dynamix/include/task_complete b/emhttp/plugins/dynamix/include/task_complete
new file mode 100755
index 0000000000..9c5ae39623
--- /dev/null
+++ b/emhttp/plugins/dynamix/include/task_complete
@@ -0,0 +1,29 @@
+#!/usr/bin/php -q
+
+
+/**
+ * Stamp a task's terminal state from the launched command itself (see
+ * TaskQueue.php / task_launch). Invoked as the tail of the backgrounded command:
+ *
+ * task_complete
+ *
+ * Recording completion at the source means a task is marked done/error the
+ * instant its command exits, independent of the scheduler daemon observing the
+ * PID disappear (which it can miss on PID reuse or a daemon-restart race).
+ */
+$docroot = '/usr/local/emhttp';
+require_once "$docroot/plugins/dynamix/include/TaskQueue.php";
+
+task_complete($argv[1] ?? '', (int)($argv[2] ?? 0));
+?>
diff --git a/emhttp/plugins/dynamix/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks
new file mode 100755
index 0000000000..f9d6556f61
--- /dev/null
+++ b/emhttp/plugins/dynamix/nchan/tasks
@@ -0,0 +1,102 @@
+#!/usr/bin/php -q
+
+
+/**
+ * Task scheduler daemon (see TaskQueue.php).
+ *
+ * Primary completion is recorded by each task itself on exit (task_complete,
+ * wired up in task_launch). This daemon is the fallback + queue driver: it
+ * catches tasks whose process vanished without stamping a result (e.g. a hard
+ * kill), marks them error, completes bounded abort escalation, auto-starts the
+ * next queued task of that type,
+ * and broadcasts the updated list. Exits once nothing is running or queued; it
+ * is (re)started on demand by task_create() and by the page-load nchan sweep in
+ * DefaultPageLayout.php.
+ */
+
+$docroot = '/usr/local/emhttp';
+require_once "$docroot/plugins/dynamix/include/TaskQueue.php";
+
+// task_log_has_error() is shared with task_complete and lives in TaskQueue.php.
+
+// tidy up stale finished tasks, then publish the current state for any client
+task_prune();
+task_publish();
+
+while (true) {
+ $changed = false;
+ $freed = [];
+
+ foreach (task_list() as $candidate) {
+ if (!in_array($candidate['status'], ['running','aborting'], true)) continue;
+ $lock = task_type_lock($candidate['type']);
+ if (!$lock) continue;
+ // Re-read under the same type lock used by task_complete/abort. The outer
+ // task_list snapshot may be stale by the time the daemon reaches it.
+ $t = task_read($candidate['id']);
+ if (!$t || !in_array($t['status'], ['running','aborting'], true)) {
+ task_type_unlock($lock);
+ continue;
+ }
+ $alive = task_process_group_alive($t);
+ if ($t['status']==='aborting' && $alive) {
+ if (time() - (int)($t['abort_requested'] ?? 0) >= TASK_ABORT_GRACE)
+ task_signal_group($t, 'KILL');
+ task_type_unlock($lock);
+ continue;
+ }
+ if (!$alive) {
+ // A successful/ordinary failure exit stamps its authoritative result via
+ // task_complete before disappearing. Reaching this fallback means the
+ // wrapper was killed, crashed, or never reached its stamp, so success is
+ // unknown and must fail closed even when no _ERROR_ record was published.
+ $t['status'] = 'error';
+ $t['finished'] = time();
+ if (task_write($t)) {
+ @unlink(task_dir().'/.launch-'.$t['id']);
+ task_advance_locked($t['type']);
+ $changed = true;
+ }
+ }
+ task_type_unlock($lock);
+ }
+
+ // 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);
+
+ if ($changed) task_publish();
+
+ // exit when there is no more work; the daemon is restarted on demand
+ $active = false;
+ foreach (task_list() as $t) {
+ if (in_array($t['status'], ['running','aborting','queued'], true)) { $active = true; break; }
+ }
+ if (!$active) break;
+
+ usleep(250000); // 250ms, same cadence as the file_manager worker
+}
+
+removeNChanScript();
+?>
diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css
index 17f5015393..85954b4b9e 100755
--- a/emhttp/plugins/dynamix/styles/default-base.css
+++ b/emhttp/plugins/dynamix/styles/default-base.css
@@ -1912,12 +1912,402 @@ label.checkbox input:disabled ~ .checkmark {
font-size: 2.5rem;
z-index: 999;
}
+/* Background operation task tray (see BodyInlineJS trayRender) */
+.op-tray {
+ position: fixed;
+ right: 1rem;
+ bottom: 1rem;
+ width: 320px;
+ max-width: 90vw;
+ max-height: 60vh;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: .25rem;
+ 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. */
+@media (min-width: 768px) {
+ .op-tray { bottom: calc(40px + 1rem); }
+}
+@media (min-height: 500px) and (orientation: landscape) {
+ .op-tray { bottom: calc(30px + 1rem); }
+}
+.op-tray .op-tray-head {
+ display: flex;
+ 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;
+ color: var(--text-color);
+ opacity: .75;
+ cursor: pointer;
+ 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;
+ gap: .5rem;
+ padding: .5rem .75rem;
+ font-size: 1.3rem;
+ color: var(--text-color);
+ background-color: var(--background-color);
+ border: 1px solid var(--border-color);
+ border-left: 3px solid var(--brand-orange);
+ border-radius: .5rem;
+ box-shadow: 0 1px 4px rgba(0,0,0,.25);
+}
+.op-tray .op-task.op-done { border-left-color: var(--green-800); }
+.op-tray .op-task.op-error { border-left-color: var(--red-600); }
+.op-tray .op-task.op-queued { border-left-color: var(--gray-500); }
+.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;
+}
+.op-tray .op-actions {
+ flex: 0 0 auto;
+ display: flex;
+ gap: .5rem;
+}
+.op-tray .op-act {
+ cursor: pointer;
+ color: var(--text-color);
+ 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;
+ /* user-resizable width: drag the right-edge grip to widen. The chosen width
+ is stored per browser in localStorage and re-applied via --nchan-sheet-width
+ (see applyNchanSheetWidth / ensureNchanResizer in BodyInlineJS), shared by
+ every .nchan sheet. Defaults to the 60rem width, always capped to viewport. */
+ width: min(var(--nchan-sheet-width, 60rem), 90vw);
+ 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 (task sheets) / close (changelog, alert sheets) control —
+ theme-token colors so it stays visible on the modal surface in every theme.
+ Scoped to .nchan so a control left on the shared swal node never shows on an
+ ordinary dialog. */
+.sweet-alert:not(.nchan) .nchan-close { display: none; }
+.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);
+}
+/* Right-edge grip to widen the task sheet; the width it sets is remembered per
+ browser (ensureNchanResizer in BodyInlineJS). Only shown on the .nchan
+ task sheet, never on other reused .sweet-alert dialogs. */
+.sweet-alert .nchan-resize { display: none; }
+.sweet-alert.nchan .nchan-resize {
+ display: block;
+ position: absolute;
+ top: 3.4rem; /* clear the minimize button in the top-right corner */
+ right: 0;
+ bottom: 0;
+ width: 10px;
+ cursor: ew-resize;
+ z-index: 1; /* below .nchan-close (z-index:2) */
+ touch-action: none; /* pointer events drive the drag, not scroll */
+}
+.sweet-alert.nchan .nchan-resize::before {
+ content: "";
+ position: absolute;
+ top: 50%;
+ right: 3px;
+ width: 3px;
+ height: 3.6rem;
+ margin-top: -1.8rem;
+ border-radius: 2px;
+ background: var(--border-color);
+ transition: background-color .15s;
+}
+.sweet-alert.nchan .nchan-resize:hover::before { background: 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; }
+ .sweet-alert.nchan .nchan-resize { display: none; } /* fullscreen: nothing to resize */
+}
.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;
}
@@ -2841,4 +3231,4 @@ div#title.ud {
label.checkbox input:checked ~ .checkmark {
background-color: var(--brand-orange);
}
-}
\ No newline at end of file
+}
diff --git a/etc/rc.d/rc.nginx b/etc/rc.d/rc.nginx
index 5bb087071d..1197a3d57e 100755
--- a/etc/rc.d/rc.nginx
+++ b/etc/rc.d/rc.nginx
@@ -170,6 +170,30 @@ build_servers(){
nchan_message_buffer_length $arg_buffer_length;
nchan_message_timeout 30s;
}
+ # Capture task output at the publisher boundary. Existing plugin scripts
+ # (including Community Apps and plugin_rm) POST directly to /pub/plugins
+ # and never call WebGUI's publish(), so this hook is the only common path
+ # for both legacy and first-party publishers.
+ location ~ ^/pub/(plugins|docker|vmaction)$ {
+ nchan_publisher;
+ nchan_channel_id "$1";
+ nchan_message_buffer_length $arg_buffer_length;
+ nchan_message_timeout 0;
+ nchan_publisher_upstream_request /task-capture;
+ }
+ location = /task-capture {
+ internal;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/local/emhttp/plugins/dynamix/include/TaskCapture.php;
+ fastcgi_param SCRIPT_NAME /plugins/dynamix/include/TaskCapture.php;
+ fastcgi_param REQUEST_URI "/task-capture/$nchan_channel_id";
+ # Nchan forwards the published message as the request body. Use GET
+ # for the internal FastCGI hop so the global browser-facing CSRF
+ # check does not reject this trusted, non-browser request.
+ fastcgi_param REQUEST_METHOD GET;
+ fastcgi_param QUERY_STRING "type=$nchan_channel_id";
+ fastcgi_param HTTP_X_TASK_CAPTURE 1;
+ }
location ~ /pub/(.*)$ {
nchan_publisher;
nchan_channel_id "$1";