From f98889970488a96c551f9c3d1f4ac49fbf861bf0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Jun 2026 07:20:31 -0400 Subject: [PATCH 01/23] feat(webgui): backend-tracked task queue with multi-task tray + foreground recall Replace the single-backgrounded-task / single-banner model with a backend-owned task queue shared across subsystems (plugins/Docker/VM) and all clients. - New TaskQueue.php store + scheduler: one JSON file per task under /var/local/emhttp/tasks, one-running-per-type invariant so the shared /sub/{plugins,docker,vmaction} channels never interleave; extra same-type ops are queued and auto-advanced. - New TaskCommand.php endpoint (create/abort/dismiss/log/list); every mutation rebroadcasts the list on /sub/tasks. - New nchan/tasks daemon (mirrors nchan/file_manager): watches pids, marks done/error, advances the queue, exits when idle. - publish.php: env-var-gated (NCHAN_TASK) per-task log tee for foreground replay; no-op on every other publish path. - DefaultPageLayout/Head/BodyInlineJS: /sub/tasks subscriber, router/renderer split of the live handlers, task tray; openPlugin/openDocker/openVMAction keep their signatures. - Tray DOM (#opTray) + theme-aware CSS. Validated with php -l, node --check, and a sandboxed store-logic test. Not yet runtime-tested on a live Unraid box (nchan pub/sub, real process launch). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dynamix/include/DefaultPageLayout.php | 4 + .../DefaultPageLayout/BodyInlineJS.php | 241 +++++++++++++----- .../DefaultPageLayout/HeadInlineJS.php | 152 +++-------- .../DefaultPageLayout/MiscElementsBottom.php | 2 + .../plugins/dynamix/include/TaskCommand.php | 72 ++++++ emhttp/plugins/dynamix/include/TaskQueue.php | 187 ++++++++++++++ emhttp/plugins/dynamix/include/publish.php | 9 + emhttp/plugins/dynamix/nchan/tasks | 73 ++++++ .../plugins/dynamix/styles/default-base.css | 48 ++++ 9 files changed, 597 insertions(+), 191 deletions(-) create mode 100644 emhttp/plugins/dynamix/include/TaskCommand.php create mode 100644 emhttp/plugins/dynamix/include/TaskQueue.php create mode 100755 emhttp/plugins/dynamix/nchan/tasks diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout.php b/emhttp/plugins/dynamix/include/DefaultPageLayout.php index c37cc22d63..38d20044d9 100755 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout.php @@ -62,6 +62,10 @@ if ($wlan0) { $nchan[] = 'webGui/nchan/wlan0'; } +// keep the task scheduler alive while background operations exist (it exits when idle) +if (glob('/var/local/emhttp/tasks/*.json')) { + $nchan[] = 'plugins/dynamix/nchan/tasks'; +} // build nchan scripts from found pages $allPages = array_merge($taskPages, $buttonPages, $pages); foreach ($allPages as $page) { diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index bb25eb01ea..e783abcc6d 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -90,45 +90,69 @@ function wlanSettings() { nchan_wlan0.start(); -var nchan_plugins = new NchanSubscriber('/sub/plugins',{subscriber:'websocket', reconnectTimeout:5000}); -nchan_plugins.on('message', function(data) { - if (!data || openDone(data)) return; - var box = $('pre#swaltext'); - const text = box.html().split('
'); - if (data.slice(-1) == '\r') { - text[text.length-1] = data.slice(0,-1); - } else { - text.push(data.slice(0,-1)); - } - box.html(text.join('
')).scrollTop(box[0].scrollHeight); -}); +// =========================================================================== +// Multi-task background operation system (shared across clients/subsystems) +// --------------------------------------------------------------------------- +// The backend (TaskQueue.php + the `tasks` daemon) owns the queue: at most one +// RUNNING op per type, so the live /sub/ channels never interleave. The +// full task list is broadcast on /sub/tasks; per-task output is captured to a +// server-side log and replayed when a task is brought to the foreground. +// =========================================================================== +var nchan_plugins = new NchanSubscriber('/sub/plugins',{subscriber:'websocket', reconnectTimeout:5000}); +var nchan_docker = new NchanSubscriber('/sub/docker',{subscriber:'websocket', reconnectTimeout:5000}); +var nchan_vmaction = new NchanSubscriber('/sub/vmaction',{subscriber:'websocket', reconnectTimeout:5000}); +const nchanByType = {plugins:nchan_plugins, docker:nchan_docker, vmaction:nchan_vmaction}; + +const TASK_ENDPOINT = '/plugins/dynamix/include/TaskCommand.php'; +var taskList = []; +const taskPrev = {}; +var foregroundTaskId = null; +var foregroundType = null; + +function taskById(id) { for (var i=0;i').text(s==null?'':String(s)).html(); } + +function stopAllTypeChannels(){ nchan_plugins.stop(); nchan_docker.stop(); nchan_vmaction.stop(); } + +// progress_dots / progress_span (declared in HeadInlineJS) are global wait-spinner +// timers keyed by element id. Clear them when (re)opening or closing a modal so a +// re-foregrounded docker/vm op doesn't spawn duplicate tickers or tick a dead node. +function clearProgressDots(){ + for (var k in progress_dots) if (progress_dots[k]) clearInterval(progress_dots[k]); + progress_dots = []; progress_span = []; +} -var nchan_docker = new NchanSubscriber('/sub/docker',{subscriber:'websocket', reconnectTimeout:5000}); -nchan_docker.on('message', function(data) { - if (!data || openDone(data)) return; +// render one raw nchan message into the open modal (#swaltext) +function renderMessage(type, data) { var box = $('pre#swaltext'); + if (!box.length) return; + if (type=='plugins') { + const text = box.html().split('
'); + if (data.slice(-1) == '\r') text[text.length-1] = data.slice(0,-1); + else text.push(data.slice(0,-1)); + box.html(text.join('
')).scrollTop(box[0].scrollHeight); + return; + } + // docker + vmaction share the \0-delimited protocol (differ only in addToID label) data = data.split('\0'); switch (data[0]) { case 'addLog': var rows = document.getElementsByClassName('logLine'); - if (rows.length) { - var row = rows[rows.length-1]; - row.innerHTML += data[1]+'
'; - } + if (rows.length) rows[rows.length-1].innerHTML += data[1]+'
'; break; case 'progress': var rows = document.getElementsByClassName('progress-'+data[1]); - if (rows.length) { - rows[rows.length-1].textContent = data[2]; - } + if (rows.length) rows[rows.length-1].textContent = data[2]; break; case 'addToID': + var label = type=='docker' ? 'IMAGE ID ['+data[1]+']' : data[1]; var rows = document.getElementById(data[1]); if (rows === null) { rows = document.getElementsByClassName('logLine'); if (rows.length) { var row = rows[rows.length-1]; - row.innerHTML += 'IMAGE ID ['+data[1]+']: '+data[2]+'.
'; + row.innerHTML += ''+label+': '+data[2]+'.
'; } } else { var rows_content = rows.getElementsByClassName('content'); @@ -143,64 +167,142 @@ function wlanSettings() { break; case 'stop_Wait': clearInterval(progress_dots[data[1]]); - progress_span[data[1]].innerHTML = ''; + if (progress_span[data[1]]) progress_span[data[1]].innerHTML = ''; break; default: box.html(box.html()+data[0]); break; } box.scrollTop(box[0].scrollHeight); -}); +} -var nchan_vmaction = new NchanSubscriber('/sub/vmaction',{subscriber:'websocket', reconnectTimeout:5000}); -nchan_vmaction.on('message', function(data) { - if (!data || openDone(data) || openError(data)) return; - var box = $('pre#swaltext'); - data = data.split('\0'); - switch (data[0]) { - case 'addLog': - var rows = document.getElementsByClassName('logLine'); - if (rows.length) { - var row = rows[rows.length-1]; - row.innerHTML += data[1]+'
'; - } - break; - case 'progress': - var rows = document.getElementsByClassName('progress-'+data[1]); - if (rows.length) { - rows[rows.length-1].textContent = data[2]; +// live channel messages render only into the foregrounded task's modal +function routeMessage(type, data) { + if (!data) return; + if (data=='_DONE_' || data=='_ERROR_') { + if (foregroundTaskId && foregroundType==type) { if (data=='_ERROR_') openError(data); else openDone(data); } + return; + } + if (foregroundTaskId && foregroundType==type) renderMessage(type, data); +} +nchan_plugins.on('message', function(data){ routeMessage('plugins', data); }); +nchan_docker.on('message', function(data){ routeMessage('docker', data); }); +nchan_vmaction.on('message', function(data){ routeMessage('vmaction', data); }); + +// legacy per-op reload callback ( (func||'loadlist')(plg) ), suppressed for ':return' +function fireTaskCallback(t) { + if (t && t.plg && t.plg != ':return') { + var fn = window[t.func || 'loadlist']; + if (typeof fn === 'function') setTimeout(function(){ fn(t.plg); },250); + } +} + +// bring a task to the foreground: open the modal, replay its server-side log, +// then stream live if it is still running +function foregroundTask(id) { + var task = taskById(id); + if (!task) return; + foregroundTaskId = 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' ? "" + : task.status=='error' ? "" + : " "; + swal({title:task.title + ' - '+titleState+'',text:"

",html:true,animation:'none',showConfirmButton:showConfirm,confirmButtonText:""},function(close){ + 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); + trayRender(); + }); + $('.sweet-alert').addClass('nchan'); + $('button.confirm').prop('disabled',disable); + $('pre#swaltext').html(''); + $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata){ + if (foregroundTaskId!==id) return; // user moved on while loading + var msgs = (logdata||'').split('\x1e'); + for (var i=0;i'+data[1]+': '+data[2]+'.
'; - } - } else { - var rows_content = rows.getElementsByClassName('content'); - if (!rows_content.length || rows_content[rows_content.length-1].textContent != data[2]) { - rows.innerHTML += ''+data[2]+'.'; + var fresh = taskById(id); + if (fresh && fresh.status=='running') nchanByType[task.type].start(); + else if (fresh && fresh.status=='done') openDone('_DONE_'); + else if (fresh && fresh.status=='error') openError('_ERROR_'); + },'text'); +} + +// react to the shared task list pushed on /sub/tasks +function onTaskListUpdate() { + for (var i=0;i "); + nchanByType[t.type].start(); } } - 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; + taskPrev[t.id] = t.status; } - box.scrollTop(box[0].scrollHeight); + for (var id in taskPrev) if (!taskById(id)) delete taskPrev[id]; + trayRender(); +} + +var taskChannel = new NchanSubscriber('/sub/tasks',{subscriber:'websocket', reconnectTimeout:5000}); +taskChannel.on('message', function(msg){ + try { taskList = JSON.parse(msg) || []; } catch(e) { taskList = []; } + onTaskListUpdate(); }); +// render the task tray +function trayRender() { + var $tray = $('#opTray'); + if (!$tray.length) return; + if (!taskList.length) { $tray.hide().empty(); return; } + var rows = ''; + for (var i=0;i\">"; + if (t.status=='running') { + icon = ""; + actions = show + "\">"; + } else if (t.status=='queued') { + icon = ""; + actions = "\">"; + } else if (t.status=='done') { + icon = ""; + actions = show + "\">"; + } else { + icon = ""; + actions = show + "\">"; + } + rows += "
"+icon+""+escapeTaskHtml(t.title)+""+actions+"
"; + } + $tray.html(rows).show(); +} + +function cancelTask(id) { $.post(TASK_ENDPOINT,{action:'abort',id:id}); } +function dismissTask(id){ $.post(TASK_ENDPOINT,{action:'dismiss',id:id}); } +function confirmAbortTask(id) { + swal({title:"",text:"",html:true,animation:'none',type:'warning',showCancelButton:true,confirmButtonText:"",cancelButtonText:""},function(){ + $.post(TASK_ENDPOINT,{action:'abort',id:id}); + }); +} + const scrollDuration = 500; $(window).scroll(function() { if ($(this).scrollTop() > 0) { @@ -259,7 +361,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(" "); diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index f8b697a55c..b7a299071f 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 = ""; @@ -170,126 +164,40 @@ 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') == '') { - setTimeout((func||'loadlist')+'("'+plg+'")',250); - } else if ('Plugins' == '') { - 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',''); - 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 + ' - ',text:"

",html:true,animation:'none',showConfirmButton:button==0,confirmButtonText:""},function(close){ - nchan_plugins.stop(); - $('div.spinner.fixed').hide(); - $('.sweet-alert').hide('fast').removeClass('nchan'); - setTimeout(function(){bannerAlert(" ["+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 + ' - ',text:"

",html:true,animation:'none',showConfirmButton:button!=0,confirmButtonText:""},function(close){ - nchan_docker.stop(); - $('div.spinner.fixed').hide(); - $('.sweet-alert').hide('fast').removeClass('nchan'); - setTimeout(function(){bannerAlert(" ["+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; - } - swal({title:title + ' - ',text:"

",html:true,animation:'none',showConfirmButton:button!=0,confirmButtonText:""},function(close){ - nchan_vmaction.stop(); - $('div.spinner.fixed').hide(); - $('.sweet-alert').hide('fast').removeClass('nchan'); - setTimeout(function(){bannerAlert(" ["+pid.toString().padStart(8,'0')+"]\" onclick='abortOperation("+pid+")'>",cmd,plg,func,start);}); - }); - $('.sweet-alert').addClass('nchan'); - $('button.confirm').prop('disabled',button==0); - }); +// 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 : show/hide the CLOSE button (per-type meaning preserved downstream) +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) foregroundTask(res.id); + },'json'); } +// 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:"",text:"",html:true,animation:'none',type:'warning',showCancelButton:true,confirmButtonText:"",cancelButtonText:""},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}); }); } 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/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php new file mode 100644 index 0000000000..e4716612a2 --- /dev/null +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -0,0 +1,72 @@ + +$task['id'],'status'=>$task['status']] : ['error'=>'invalid'])); + +case 'abort': + $task = task_read($id); + if ($task) { + if ($task['status']==='running' && $task['pid'] > 1) { + exec('kill '.escapeshellarg($task['pid'])); + foreach (glob('/tmp/plugins/pluginPending/*') ?: [] as $file) @unlink($file); + $task['status'] = 'error'; + $task['finished'] = time(); + task_write($task); + task_advance($task['type']); + } else { + // queued (or already finished) task: just drop it + task_delete($id); + } + task_publish(); + } + die(); + +case 'dismiss': + $task = task_read($id); + if ($task && in_array($task['status'],['done','error'])) { + task_delete($id); + task_publish(); + } + die(); + +case 'log': + // output captured so far, for foreground replay + header('Content-Type: text/plain'); + if (task_valid_id($id) && is_file(task_log($id))) readfile(task_log($id)); + die(); + +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..41f095643a --- /dev/null +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -0,0 +1,187 @@ + +.json per task plus a per-task .log + * capturing the operation's nchan output (written by publish.php when the + * NCHAN_TASK env var is set). The full task list is broadcast to all clients + * on the `tasks` nchan channel whenever it changes. + * + * Scheduling rule: at most one RUNNING task per type at any time, so the + * existing shared live channels (/sub/plugins, /sub/docker, /sub/vmaction) + * never have two concurrent publishers. Additional same-type operations are + * queued and auto-started by the `tasks` daemon when the running one finishes. + */ + +$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_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_delete($id) { + if (!task_valid_id($id)) return; + delete_file(task_path($id), task_log($id)); +} + +// 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 && $t['status']==='running') return $t; + return null; +} + +// 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, capturing its output to .log via NCHAN_TASK +function task_launch(&$task) { + // 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(); + task_write($task); + return false; + } + [$name,$args] = $resolved; + // 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 \$!"); + $task['pid'] = $pid; + $task['status'] = 'running'; + $task['started'] = time(); + task_write($task); + return $pid; +} + +// start the next queued task of a type if nothing of that type is running +function task_advance($type) { + if (task_running_type($type)) return; + foreach (task_list() as $t) { + if ($t['type']===$type && $t['status']==='queued') { task_launch($t); return; } + } +} + +// (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)) 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'])) + return $t; + } + } + $task = [ + 'id' => uniqid(), + 'type' => $type, + 'title' => $title, + 'cmd' => $cmd, + 'plg' => $plg, + 'func' => $func, + 'start' => (int)$start, + 'button' => (int)$button, + 'pid' => '', + 'status' => 'queued', + 'created' => time(), + 'started' => 0, + 'finished' => 0, + ]; + task_write($task); + if (!task_running_type($type)) task_launch($task); + task_daemon_start(); + task_publish(); + return task_read($task['id']) ?: $task; +} + +// 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']); + } +} +?> diff --git a/emhttp/plugins/dynamix/include/publish.php b/emhttp/plugins/dynamix/include/publish.php index b50ff02865..8168acc377 100755 --- a/emhttp/plugins/dynamix/include/publish.php +++ b/emhttp/plugins/dynamix/include/publish.php @@ -33,6 +33,15 @@ function curl_socket($socket, $url, $message='') { function publish($endpoint, $message, $len=1, $abort=false, $abortTime=30) { static $abortStart = [], $com = [], $lens = []; + // When launched by the task queue (TaskQueue.php), capture every published + // message to the task's log so it can be replayed when the task is brought + // back to the foreground. Messages are delimited by RS (\x1e) to preserve + // boundaries even when a message itself contains newlines. + $taskId = getenv('NCHAN_TASK'); + if ($taskId !== false && $taskId !== '' && ctype_xdigit($taskId)) { + @file_put_contents("/var/local/emhttp/tasks/$taskId.log", $message."\x1e", FILE_APPEND); + } + if ( is_file("/tmp/publishPaused") ) return false; diff --git a/emhttp/plugins/dynamix/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks new file mode 100755 index 0000000000..e963cbd6b4 --- /dev/null +++ b/emhttp/plugins/dynamix/nchan/tasks @@ -0,0 +1,73 @@ +#!/usr/bin/php -q + + exists +function task_pid_alive($pid) { + return $pid && file_exists("/proc/$pid"); +} + +// the operation reported a failure if its captured output contains the _ERROR_ marker +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; +} + +// 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 $t) { + if ($t['status']==='running' && !task_pid_alive($t['pid'])) { + $t['status'] = task_log_has_error($t['id']) ? 'error' : 'done'; + $t['finished'] = time(); + task_write($t); + $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 ($t['status']==='running' || $t['status']==='queued') { $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..6a36e98beb 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1912,6 +1912,54 @@ 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: 999; +} +.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; + 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); } .back_to_top { right: 40px; } From 73fc6db90211086deda1d18e582091f9b863c6ea Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 11:34:52 -0400 Subject: [PATCH 02/23] fix(task-tray): lift op-tray above fixed footer so it isn't clipped The background-operation task tray was fixed at bottom:1rem with z-index 999, but #footer is position:fixed bottom:0 with z-index 10000, so the tray was painted behind and clipped by the footer in the bottom-right corner. Raise the tray's bottom offset above the footer using the same media queries that make the footer fixed. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 6a36e98beb..9697de3574 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1926,6 +1926,14 @@ label.checkbox input:disabled ~ .checkmark { gap: .25rem; z-index: 999; } +/* 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-task { display: flex; align-items: center; From 6c6708e55fbd4a3b3af8338c3ecc276c02b42f5b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 11:43:33 -0400 Subject: [PATCH 03/23] fix(task-tray): open modal on create even before /sub/tasks broadcast createTask() called foregroundTask(res.id) immediately on the AJAX response, but foregroundTask() looks the task up in taskList, which is only populated asynchronously by the /sub/tasks websocket broadcast. When the broadcast lands after the AJAX response, taskById() returns null and foregroundTask() bails, so no modal opens even though the command runs and its output is captured server-side. This made actions like the plugin 'Check For Updates' appear to do nothing. Seed an optimistic taskList entry from the data we already have so the modal foregrounds immediately; onTaskListUpdate() reconciles it when the authoritative broadcast arrives. Co-Authored-By: Claude Opus 4.8 --- .../include/DefaultPageLayout/HeadInlineJS.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index b7a299071f..8b336954f3 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -187,7 +187,20 @@ function createTask(type,cmd,title,plg,func,start,button) { plg:plg||'', func:func||'', start:start||0, button:button||0 },function(res) { $('div.spinner.fixed').hide(); - if (res && res.id) foregroundTask(res.id); + if (!res || !res.id) return; + // 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}); + if (typeof trayRender==='function') trayRender(); + } + foregroundTask(res.id); },'json'); } From f6d8cfcd7951b879cbd6cabe45946d7775834dc0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 11:47:11 -0400 Subject: [PATCH 04/23] fix(task-tray): guard NchanSubscriber start/stop against wrong run-state NchanSubscriber.start()/stop() throw if called when already running / not running ('Can't stop NchanSubscriber, it's not running.'). stopAllTypeChannels() called .stop() on all three type channels unconditionally, and foregroundTask() runs it at the top before any channel is started, so the uncaught throw aborted the function before the modal opened. Wrap start/stop in nchanStart()/nchanStop() helpers that check the .running flag (with a try/catch safety net) and route the type-channel transitions through them. Co-Authored-By: Claude Opus 4.8 --- .../include/DefaultPageLayout/BodyInlineJS.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index e783abcc6d..e0ebcef2c8 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -113,7 +113,12 @@ function taskById(id) { for (var i=0;i').text(s==null?'':String(s)).html(); } -function stopAllTypeChannels(){ nchan_plugins.stop(); nchan_docker.stop(); nchan_vmaction.stop(); } +// NchanSubscriber.start()/stop() throw when called in the wrong run-state +// ("Can't stop NchanSubscriber, it's not running."). Guard every transition so +// a no-op start/stop can't raise and abort the surrounding handler. +function nchanStart(sub){ try { if (sub && !sub.running) sub.start(); } catch(e) {} } +function nchanStop(sub) { try { if (sub && sub.running) sub.stop(); } catch(e) {} } +function stopAllTypeChannels(){ nchanStop(nchan_plugins); nchanStop(nchan_docker); nchanStop(nchan_vmaction); } // progress_dots / progress_span (declared in HeadInlineJS) are global wait-spinner // timers keyed by element id. Clear them when (re)opening or closing a modal so a @@ -232,7 +237,7 @@ function foregroundTask(id) { renderMessage(task.type, m); } var fresh = taskById(id); - if (fresh && fresh.status=='running') nchanByType[task.type].start(); + if (fresh && fresh.status=='running') nchanStart(nchanByType[task.type]); else if (fresh && fresh.status=='done') openDone('_DONE_'); else if (fresh && fresh.status=='error') openError('_ERROR_'); },'text'); @@ -253,7 +258,7 @@ function onTaskListUpdate() { } } else if (t.status=='running' && foregroundTaskId==t.id) { $('#pluginProgressTitle').html(" "); - nchanByType[t.type].start(); + nchanStart(nchanByType[t.type]); } } taskPrev[t.id] = t.status; From 856cc7d8171b50f6b73a97418416437bbc3d772e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 11:55:01 -0400 Subject: [PATCH 05/23] feat(task-tray): auto-expire finished tasks and add Clear finished action Finished tasks previously only left the tray on manual dismissal or a 1-day prune that ran solely at daemon startup, so completed tasks piled up and users cleared them one by one. - Successful tasks now auto-expire 30s after they finish (TASK_DONE_TTL); the scheduler daemon lingers (1s cadence) while any success is pending expiry so it can prune and re-broadcast, then exits as before. - Failures persist (TASK_ERROR_TTL, ~1 day) so errors aren't swept away before they're noticed. - Add a 'Clear finished' tray header action (TaskCommand 'clear' -> task_clear_finished) shown when more than one finished task is present. Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/BodyInlineJS.php | 12 ++++++-- .../plugins/dynamix/include/TaskCommand.php | 6 ++++ emhttp/plugins/dynamix/include/TaskQueue.php | 29 ++++++++++++++++--- emhttp/plugins/dynamix/nchan/tasks | 12 ++++++-- .../plugins/dynamix/styles/default-base.css | 13 +++++++++ 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index e0ebcef2c8..46feac6559 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -278,9 +278,10 @@ function trayRender() { var $tray = $('#opTray'); if (!$tray.length) return; if (!taskList.length) { $tray.hide().empty(); return; } - var rows = ''; + var rows = '', finished = 0; for (var i=0;i\">"; if (t.status=='running') { icon = ""; @@ -297,11 +298,18 @@ function trayRender() { } rows += "
"+icon+""+escapeTaskHtml(t.title)+""+actions+"
"; } - $tray.html(rows).show(); + // 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 = ""; + } + $tray.html(header + rows).show(); } function cancelTask(id) { $.post(TASK_ENDPOINT,{action:'abort',id:id}); } function dismissTask(id){ $.post(TASK_ENDPOINT,{action:'dismiss',id:id}); } +function clearFinishedTasks(){ $.post(TASK_ENDPOINT,{action:'clear'}); } function confirmAbortTask(id) { swal({title:"",text:"",html:true,animation:'none',type:'warning',showCancelButton:true,confirmButtonText:"",cancelButtonText:""},function(){ $.post(TASK_ENDPOINT,{action:'abort',id:id}); diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php index e4716612a2..8ab303c5bb 100644 --- a/emhttp/plugins/dynamix/include/TaskCommand.php +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -58,6 +58,12 @@ } 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 header('Content-Type: text/plain'); diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index 41f095643a..a61e8186f1 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -33,7 +33,8 @@ 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_DONE_TTL', 30); // auto-expire successful tasks 30s after they finish +define('TASK_ERROR_TTL', 86400); // keep failures ~1 day so errors aren't missed (also cleared via the tray) define('TASK_TYPES', ['plugins','docker','vmaction']); // task ids are produced by uniqid() => lowercase hex; validate anything used in a path @@ -176,12 +177,32 @@ function task_create($type,$cmd,$title,$plg,$func,$start,$button) { return task_read($task['id']) ?: $task; } -// remove finished tasks older than the TTL (called by the daemon on startup) +// remove finished tasks past their TTL: successes expire quickly (TASK_DONE_TTL) +// so the tray self-cleans, failures linger (TASK_ERROR_TTL) so they aren't +// missed. Returns the number of tasks pruned so the daemon can decide whether to +// re-broadcast. Called on daemon startup and each scheduling tick. function task_prune() { $now = time(); + $pruned = 0; foreach (task_list() as $t) { - if (in_array($t['status'],['done','error']) && ($now - ($t['finished'] ?: $t['created'])) > TASK_DONE_TTL) - task_delete($t['id']); + if (!in_array($t['status'],['done','error'])) continue; + $ttl = $t['status']==='error' ? TASK_ERROR_TTL : TASK_DONE_TTL; + if (($now - ($t['finished'] ?: $t['created'])) > $ttl) { task_delete($t['id']); $pruned++; } } + return $pruned; +} + +// a successful task still inside its short auto-expire window keeps the daemon +// alive so it can prune + re-broadcast it (errors do not, to avoid a day-long +// idle daemon) +function task_pending_expiry() { + foreach (task_list() as $t) if ($t['status']==='done') return true; + return false; +} + +// 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/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks index e963cbd6b4..500891a2aa 100755 --- a/emhttp/plugins/dynamix/nchan/tasks +++ b/emhttp/plugins/dynamix/nchan/tasks @@ -57,16 +57,22 @@ while (true) { // start the next queued op for every type that just freed up foreach (array_keys($freed) as $type) task_advance($type); + // auto-expire successful tasks past their short TTL so the tray self-cleans + if (task_prune() > 0) $changed = true; + if ($changed) task_publish(); - // exit when there is no more work; the daemon is restarted on demand + // keep looping while there is work, or while a successful task is still inside + // its auto-expire window (so we can prune + re-broadcast it); the daemon is + // restarted on demand once it exits $active = false; foreach (task_list() as $t) { if ($t['status']==='running' || $t['status']==='queued') { $active = true; break; } } - if (!$active) break; + if (!$active && !task_pending_expiry()) break; - usleep(250000); // 250ms, same cadence as the file_manager worker + // 250ms while actively scheduling, 1s when only waiting on auto-expire + usleep($active ? 250000 : 1000000); } removeNChanScript(); diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 9697de3574..71805c8893 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1934,6 +1934,19 @@ label.checkbox input:disabled ~ .checkmark { @media (min-height: 500px) and (orientation: landscape) { .op-tray { bottom: calc(30px + 1rem); } } +.op-tray .op-tray-head { + display: flex; + justify-content: flex-end; + padding: 0 .25rem .1rem; +} +.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); } .op-tray .op-task { display: flex; align-items: center; From 97ef66f020a31a5589d451174186b6c57a5b4983 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 12:13:07 -0400 Subject: [PATCH 06/23] revert(task-tray): drop daemon auto-expiry, keep Clear finished button Per review, the manual 'Clear finished' tray action is enough; the 30s server-side auto-expiry and the daemon lingering to prune/re-broadcast add complexity we don't need. Restore the scheduler daemon and the done/error prune TTL to their original behavior (1-day prune on daemon startup) and keep only task_clear_finished() backing the tray button. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/include/TaskQueue.php | 23 ++++---------------- emhttp/plugins/dynamix/nchan/tasks | 12 +++------- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index a61e8186f1..3ee760ebc9 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -33,8 +33,7 @@ define('TASK_DIR', '/var/local/emhttp/tasks'); define('TASK_DAEMON', 'plugins/dynamix/nchan/tasks'); -define('TASK_DONE_TTL', 30); // auto-expire successful tasks 30s after they finish -define('TASK_ERROR_TTL', 86400); // keep failures ~1 day so errors aren't missed (also cleared via the tray) +define('TASK_DONE_TTL', 86400); // prune done/error tasks after 1 day define('TASK_TYPES', ['plugins','docker','vmaction']); // task ids are produced by uniqid() => lowercase hex; validate anything used in a path @@ -177,27 +176,13 @@ function task_create($type,$cmd,$title,$plg,$func,$start,$button) { return task_read($task['id']) ?: $task; } -// remove finished tasks past their TTL: successes expire quickly (TASK_DONE_TTL) -// so the tray self-cleans, failures linger (TASK_ERROR_TTL) so they aren't -// missed. Returns the number of tasks pruned so the daemon can decide whether to -// re-broadcast. Called on daemon startup and each scheduling tick. +// remove finished tasks older than the TTL (called by the daemon on startup) function task_prune() { $now = time(); - $pruned = 0; foreach (task_list() as $t) { - if (!in_array($t['status'],['done','error'])) continue; - $ttl = $t['status']==='error' ? TASK_ERROR_TTL : TASK_DONE_TTL; - if (($now - ($t['finished'] ?: $t['created'])) > $ttl) { task_delete($t['id']); $pruned++; } + if (in_array($t['status'],['done','error']) && ($now - ($t['finished'] ?: $t['created'])) > TASK_DONE_TTL) + task_delete($t['id']); } - return $pruned; -} - -// a successful task still inside its short auto-expire window keeps the daemon -// alive so it can prune + re-broadcast it (errors do not, to avoid a day-long -// idle daemon) -function task_pending_expiry() { - foreach (task_list() as $t) if ($t['status']==='done') return true; - return false; } // remove every finished task now (the tray's "Clear finished" action) diff --git a/emhttp/plugins/dynamix/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks index 500891a2aa..e963cbd6b4 100755 --- a/emhttp/plugins/dynamix/nchan/tasks +++ b/emhttp/plugins/dynamix/nchan/tasks @@ -57,22 +57,16 @@ while (true) { // start the next queued op for every type that just freed up foreach (array_keys($freed) as $type) task_advance($type); - // auto-expire successful tasks past their short TTL so the tray self-cleans - if (task_prune() > 0) $changed = true; - if ($changed) task_publish(); - // keep looping while there is work, or while a successful task is still inside - // its auto-expire window (so we can prune + re-broadcast it); the daemon is - // restarted on demand once it exits + // exit when there is no more work; the daemon is restarted on demand $active = false; foreach (task_list() as $t) { if ($t['status']==='running' || $t['status']==='queued') { $active = true; break; } } - if (!$active && !task_pending_expiry()) break; + if (!$active) break; - // 250ms while actively scheduling, 1s when only waiting on auto-expire - usleep($active ? 250000 : 1000000); + usleep(250000); // 250ms, same cadence as the file_manager worker } removeNChanScript(); From 68114925d57e1ae5f6e8a219b52b22bb34428291 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 12:27:48 -0400 Subject: [PATCH 07/23] style(task-tray): use stop-circle instead of bomb for Abort The bomb icon read as overly aggressive for aborting a running task; fa-stop-circle conveys 'stop' more calmly while keeping the meaning. Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 46feac6559..858ce44a08 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -285,7 +285,7 @@ function trayRender() { var show = "\">"; if (t.status=='running') { icon = ""; - actions = show + "\">"; + actions = show + "\">"; } else if (t.status=='queued') { icon = ""; actions = "\">"; From b7716597c68f68bbd5a49cbf3f32143ee4b59b55 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 12:30:03 -0400 Subject: [PATCH 08/23] feat(task-tray): add indeterminate progress bar to running tasks A running task only showed a small spinning icon. Add a CSS-only indeterminate bar sweeping along the bottom of the running card so it clearly reads as still active; respects prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/styles/default-base.css | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 71805c8893..869007a6bb 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1963,6 +1963,27 @@ label.checkbox input:disabled ~ .checkmark { .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); } +/* Indeterminate progress bar sweeping along the bottom of a running task so it + clearly reads as still active (these ops don't report a real percentage). */ +.op-tray .op-task.op-running { position: relative; overflow: hidden; } +.op-tray .op-task.op-running::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + height: 2px; + width: 35%; + background-color: var(--brand-orange); + border-radius: 2px; + animation: op-running-bar 1.3s ease-in-out infinite; +} +@keyframes op-running-bar { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(390%); } +} +@media (prefers-reduced-motion: reduce) { + .op-tray .op-task.op-running::after { animation: none; left: 0; width: 100%; opacity: .35; } +} .op-tray .op-icon { flex: 0 0 auto; } .op-tray .op-title { flex: 1 1 auto; From db7c8b144ca8cdc47eae2f2a77f7ca7e2438f46e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 12:31:56 -0400 Subject: [PATCH 09/23] style(task-tray): use a plain loader spinner for running tasks Drop the indeterminate progress bar in favor of a simple loader: running tasks now use the fa-circle-o-notch spinner (clearer 'loading' read than the refresh arrows). Keeps the running indicator minimal. Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/BodyInlineJS.php | 2 +- .../plugins/dynamix/styles/default-base.css | 21 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 858ce44a08..eb40e02b7d 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -284,7 +284,7 @@ function trayRender() { if (t.status=='done' || t.status=='error') finished++; var show = "\">"; if (t.status=='running') { - icon = ""; + icon = ""; actions = show + "\">"; } else if (t.status=='queued') { icon = ""; diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 869007a6bb..71805c8893 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1963,27 +1963,6 @@ label.checkbox input:disabled ~ .checkmark { .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); } -/* Indeterminate progress bar sweeping along the bottom of a running task so it - clearly reads as still active (these ops don't report a real percentage). */ -.op-tray .op-task.op-running { position: relative; overflow: hidden; } -.op-tray .op-task.op-running::after { - content: ''; - position: absolute; - left: 0; - bottom: 0; - height: 2px; - width: 35%; - background-color: var(--brand-orange); - border-radius: 2px; - animation: op-running-bar 1.3s ease-in-out infinite; -} -@keyframes op-running-bar { - 0% { transform: translateX(-110%); } - 100% { transform: translateX(390%); } -} -@media (prefers-reduced-motion: reduce) { - .op-tray .op-task.op-running::after { animation: none; left: 0; width: 100%; opacity: .35; } -} .op-tray .op-icon { flex: 0 0 auto; } .op-tray .op-title { flex: 1 1 auto; From 3ae078d976a6ec5e78356f9a08c5f693401c6a9f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 16:26:43 -0400 Subject: [PATCH 10/23] fix(task-queue): address CodeRabbit review feedback on PR 2665 Apply robot review feedback for the backend task queue: - TaskQueue.php: escape the full bash -c payload with escapeshellarg() to close a single-quote shell-injection vector while preserving multi-arg word splitting; serialize create->launch per type with flock to keep the one-running-task-per-type invariant under concurrent requests. - nchan/tasks: require numeric pid before the /proc liveness check; recover queued-only tasks so the daemon never sleeps with pending work; match _ERROR_ as a discrete RS-delimited record (read tail in PHP). - TaskCommand.php: validate pid is numeric before kill. - BodyInlineJS.php: escape task.title in the swal title and task ids in the tray onclick handlers. - HeadInlineJS.php: surface a createTask() failure via .fail(). - default-base.css: add min-width:0 so .op-title ellipsizes reliably. Co-Authored-By: Claude Opus 4.8 --- .codex/coderabbit-fixes-wip.md | 60 +++++++++++++++++++ .../DefaultPageLayout/BodyInlineJS.php | 14 ++--- .../DefaultPageLayout/HeadInlineJS.php | 5 +- .../plugins/dynamix/include/TaskCommand.php | 2 +- emhttp/plugins/dynamix/include/TaskQueue.php | 17 +++++- emhttp/plugins/dynamix/nchan/tasks | 29 +++++++-- .../plugins/dynamix/styles/default-base.css | 1 + 7 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 .codex/coderabbit-fixes-wip.md 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/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index eb40e02b7d..5253b9741c 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -216,7 +216,7 @@ function foregroundTask(id) { var titleState = task.status=='done' ? "" : task.status=='error' ? "" : " "; - swal({title:task.title + ' - '+titleState+'',text:"

",html:true,animation:'none',showConfirmButton:showConfirm,confirmButtonText:""},function(close){ + swal({title:escapeTaskHtml(task.title) + ' - '+titleState+'',text:"

",html:true,animation:'none',showConfirmButton:showConfirm,confirmButtonText:""},function(close){ if (foregroundTaskId===id) { foregroundTaskId=null; foregroundType=null; } stopAllTypeChannels(); clearProgressDots(); @@ -280,21 +280,21 @@ function trayRender() { if (!taskList.length) { $tray.hide().empty(); return; } var rows = '', finished = 0; for (var i=0;i\">"; + 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+"
"; } diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 8b336954f3..c2091691a8 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:"",text:"",type:'error',html:true,animation:'none'}); + }); } // abortOperation(pid) retained for backward compatibility: map the pid to its 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 71805c8893..a48073e8b2 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1966,6 +1966,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; From 79f7c44e2d2c6d68cc26c13f8922236df8ab75e7 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 22 Jun 2026 19:07:48 -0400 Subject: [PATCH 11/23] fix(task-queue): record terminal state from the task itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completion was registered only by the `tasks` scheduler daemon noticing the launched PID disappear, which it can miss on PID reuse or a daemon-restart race (TOCTOU in task_daemon_start) — leaving a finished task stuck "in progress" until the next page-load nchan sweep. Have the launched command stamp its own result on exit: task_launch wraps the payload to capture the command's exit code and invoke task_complete, which marks the task done/error, advances the queue and broadcasts. This makes completion authoritative at the source. task_log_has_error moves into TaskQueue.php so the stamp and the daemon share it, and task_advance now takes the per-type lock so two advancers can't double-launch the next queued task. The daemon stays a fallback for hard-killed processes. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/include/TaskQueue.php | 70 ++++++++++++++++++-- emhttp/plugins/dynamix/include/task_complete | 29 ++++++++ emhttp/plugins/dynamix/nchan/tasks | 27 +++----- 3 files changed, 102 insertions(+), 24 deletions(-) create mode 100755 emhttp/plugins/dynamix/include/task_complete diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index 4cfba97ac2..2f4af7ce47 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -107,6 +107,7 @@ function task_resolve($cmd) { // launch a task in the background, capturing its output to .log via NCHAN_TASK 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']); @@ -120,10 +121,20 @@ 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']).' '; + // 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 for the stamp so its `tasks` broadcast isn't captured into this + // task's foreground-replay log. 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. - $payload = 'sleep .3 && '.$name.' '.$args.$suffix; + $payload = 'sleep .3 && '.$name.' '.$args.$suffix.$stamp; $pid = exec($env.'nohup bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 & echo $!'); $task['pid'] = $pid; $task['status'] = 'running'; @@ -132,12 +143,61 @@ function task_launch(&$task) { return $pid; } -// start the next queued task of a type if nothing of that type is running +// 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) { - if (task_running_type($type)) return; - foreach (task_list() as $t) { - if ($t['type']===$type && $t['status']==='queued') { task_launch($t); return; } + $lock = fopen(task_dir()."/.$type.lock", 'c'); + if ($lock) flock($lock, LOCK_EX); + if (!task_running_type($type)) { + foreach (task_list() as $t) { + if ($t['type']===$type && $t['status']==='queued') { task_launch($t); break; } + } } + if ($lock) { flock($lock, LOCK_UN); fclose($lock); } +} + +// 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; + $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 is done under the per-type lock; the lock is then +// released before advancing so the (also-locking) task_advance can't deadlock on +// the same handle. An already-finalized task (e.g. aborted via TaskCommand.php, +// or marked by the daemon first) is left untouched. +function task_complete($id, $rc) { + $task = task_read($id); + if (!$task) return; + $type = $task['type']; + $lock = fopen(task_dir()."/.$type.lock", 'c'); + if ($lock) flock($lock, LOCK_EX); + $task = task_read($id); // re-read under lock + $changed = false; + if ($task && $task['status']==='running') { + $task['status'] = ((int)$rc !== 0 || task_log_has_error($id)) ? 'error' : 'done'; + $task['finished'] = time(); + task_write($task); + $changed = true; + } + if ($lock) { flock($lock, LOCK_UN); fclose($lock); } + if ($changed) { task_advance($type); task_publish(); } } // (re)start the scheduling daemon if it isn't already running 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 + + + * + * 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 index 5b2cc265e3..2f2236885e 100755 --- a/emhttp/plugins/dynamix/nchan/tasks +++ b/emhttp/plugins/dynamix/nchan/tasks @@ -15,10 +15,13 @@ /** * Task scheduler daemon (see TaskQueue.php). * - * Watches running tasks for completion (process exit), marks them done/error, - * 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. + * 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 done/error, 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'; @@ -30,21 +33,7 @@ function task_pid_alive($pid) { return ctype_digit((string)$pid) && file_exists("/proc/$pid"); } -// 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; - $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); -} +// 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(); From 49d12bb2a9fd76b4bc8d309a80e9cef7db676d02 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 22 Jun 2026 19:32:13 -0400 Subject: [PATCH 12/23] feat(task-tray): backgroundable running ops + modal button polish (folds #2669, OS-461) Squash of feat/task-modal-buttons (PR #2669, 31 iteration commits) into the consolidated task-queue PR. Adds backgroundable running operations with a corrected modal button set, drives the plugin "Upgrading" button state from the server-side task queue, and a round of task-modal/tray styling (state strip, dismiss placement, mobile grouped-notification carousel). Touches only the tray/modal UI (BodyInlineJS, HeadInlineJS, default-base.css) and the Plugins page (Plugins.page, PluginHelpers.php); no changes to the core queue/daemon files. Co-Authored-By: Claude Opus 4.8 --- .../dynamix.plugin.manager/Plugins.page | 18 ++ .../include/PluginHelpers.php | 17 +- .../DefaultPageLayout/BodyInlineJS.php | 141 +++++++-- .../DefaultPageLayout/HeadInlineJS.php | 14 +- .../plugins/dynamix/styles/default-base.css | 291 +++++++++++++++++- 5 files changed, 452 insertions(+), 29 deletions(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index d5ade9a962..e56f9c3531 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 @@  $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 5253b9741c..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' ? "" - : task.status=='error' ? "" - : " "; - swal({title:escapeTaskHtml(task.title) + ' - '+titleState+'',text:"

",html:true,animation:'none',showConfirmButton:showConfirm,confirmButtonText:""},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' ? " " + : task.status=='error' ? " " + : " "; + swal({title:escapeTaskHtml(task.title),text:"

",html:true,animation:'none',showConfirmButton:finished,confirmButtonText:""},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 ? "" : ""; + $('.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(" "); + $('#pluginProgressTitle').attr('class','nchan-state nchan-running').html(" "); 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,14 +300,27 @@ 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 = "\">"; if (t.status=='running') { @@ -296,15 +336,74 @@ function trayRender() { icon = ""; 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+" "; + head += ""; + if (finished > 1) { + head += "\"> "; + } + 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 c2091691a8..37e5569d31 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -248,14 +248,18 @@ function openAlert(cmd,title,func) { function openDone(data) { if (data == '_DONE_') { $('div.spinner.fixed').hide(); - $('button.confirm').text("").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',""); + $('.sweet-alert').attr('data-has-confirm-button','true'); // un-hide the footer (CSS keys off this) + $('button.confirm').text("").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(""); + $('#pluginProgressTitle').attr('class','nchan-state nchan-done').html(" "); return true; } return false; @@ -264,8 +268,10 @@ function openDone(data) { function openError(data) { if (data == '_ERROR_') { $('div.spinner.fixed').hide(); - $('button.confirm').text("").prop('disabled',false).show(); - $('#pluginProgressTitle').text(""); + $('.sweet-alert .nchan-close').attr('title',""); + $('.sweet-alert').attr('data-has-confirm-button','true'); // un-hide the footer (CSS keys off this) + $('button.confirm').text("").prop('disabled',false).show(); + $('#pluginProgressTitle').attr('class','nchan-state nchan-error').html(" "); return true; } return false; diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index a48073e8b2..5b74ca9890 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1924,7 +1924,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. */ @@ -1936,8 +1936,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; @@ -1947,6 +1961,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; @@ -1982,12 +2000,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; } From 4c38213c867773c6abf960e5fee272644e26fe6f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 17:20:45 -0400 Subject: [PATCH 13/23] feat(task-tray): show target name in background-task progress headings Generic progress headings ("Updating the container", "Install Plugin") don't say what is being acted on, which is ambiguous when several operations run at once. The target is already in scope at each call site, so fold it into the title that openDocker/openPlugin render: - docker update: "Update container: " - docker update all: "Updating all Containers (N)" - plugin install: "Install Plugin: " (Apps, Tailscale) - language install: "Install Language: " Names that aren't charset-constrained (plugin/language files) are reduced to a sanitized basename slug before display, since the swal heading is rendered with html:true. Container names rely on Docker's naming charset; counts are numeric. Stays out of HeadInlineJS so it does not conflict with the backend task-queue work in #2665. OS-460 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix.docker.manager/javascript/docker.js | 4 ++-- emhttp/plugins/dynamix/Apps.page | 5 ++++- emhttp/plugins/dynamix/Language.page | 5 ++++- emhttp/plugins/dynamix/Tailscale.page | 5 ++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js index fd8599c337..bbf8b3543d 100644 --- a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js +++ b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js @@ -107,7 +107,7 @@ function updateContainer(container) { swal({ title:_('Are you sure?'),text:_('Update container')+': '+container, type:'warning',html:true,showCancelButton:true,closeOnConfirm:false,confirmButtonText:_('Yes, update it!'),cancelButtonText:_('Cancel') },function(){ - openDocker('update_container '+encodeURIComponent(container),_('Updating the container'),'','loadlist'); + openDocker('update_container '+encodeURIComponent(container),_('Update container')+': '+container,'','loadlist'); }); } function rmContainer(container, image, id) { @@ -182,7 +182,7 @@ function updateAll() { $('input[type=button]').prop('disabled',true); var ct = []; for (var i=0,d; d=docker[i]; i++) if (d.update==1) ct.push(encodeURIComponent(d.name)); - openDocker('update_container '+ct.join('*'),_('Updating all Containers'),'','loadlist'); + openDocker('update_container '+ct.join('*'),_('Updating all Containers')+' ('+ct.length+')','','loadlist'); } function rebuildAll() { $('input[type=button]').prop('disabled',true); diff --git a/emhttp/plugins/dynamix/Apps.page b/emhttp/plugins/dynamix/Apps.page index b9399d7502..14e614bb44 100644 --- a/emhttp/plugins/dynamix/Apps.page +++ b/emhttp/plugins/dynamix/Apps.page @@ -16,7 +16,10 @@ Code="e942" ?> diff --git a/emhttp/plugins/dynamix/Language.page b/emhttp/plugins/dynamix/Language.page index af7e27ef67..0214954a5f 100644 --- a/emhttp/plugins/dynamix/Language.page +++ b/emhttp/plugins/dynamix/Language.page @@ -75,7 +75,10 @@ function getZIPfile(event,form) { } function installXML(name) { var file = name.trim(); - if (file) openPlugin('language install '+file, "_(Install Language)_"); + // show which language pack in the progress heading; sanitize to a safe display + // slug (the heading is rendered as HTML), derived from the file basename + var disp = (file.split('/').pop()||'').replace(/\.[a-z0-9]+$/i,'').replace(/[^\w.\- ]+/g,' ').trim(); + if (file) openPlugin('language install '+file, "_(Install Language)_"+(disp?': '+disp:'')); } $(function() { $('input.view').switchButton({labels_placement:'left', off_label:"_(User)_", on_label:"_(Developer)_"}); diff --git a/emhttp/plugins/dynamix/Tailscale.page b/emhttp/plugins/dynamix/Tailscale.page index b3a797619d..223d7b78a5 100644 --- a/emhttp/plugins/dynamix/Tailscale.page +++ b/emhttp/plugins/dynamix/Tailscale.page @@ -16,7 +16,10 @@ Title="Tailscale" ?> From 0fe05ad5e2f102dd13ed6c82b7ba28ae64533097 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 8 Jul 2026 17:54:57 -0400 Subject: [PATCH 14/23] fix(task-queue): don't re-fire completion callback for pre-existing done tasks onTaskListUpdate fired a background task's one-shot completion callback whenever it saw the task transition to done/error. But taskPrev resets to {} on every page load, and /sub/tasks is an nchan channel that redelivers its retained message to each new subscriber, so a task that was already terminal before the page loaded looked like a fresh transition every load. For reload-style callbacks (func:'refresh') this is an infinite loop: load -> subscribe -> see stale done task as new -> refresh() -> reload -> repeat. An OS-update task left as done with func:'refresh' pinned the entire webGUI in a ~0.7s full-page reload cycle (screen flashing on every page). Only fire the callback when we actually observed the task run and then finish during this page's lifetime (prev !== undefined). A task already terminal on first sight is stale: its effect is applied server-side and it still shows in the tray for the user to dismiss. --- .../include/DefaultPageLayout/BodyInlineJS.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index b5891233f3..fe67904db9 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -277,7 +277,19 @@ function onTaskListUpdate() { stopAllTypeChannels(); if (t.status=='error') openError('_ERROR_'); else openDone('_DONE_'); // callback fired when the user closes the modal - } else { + } else if (prev !== undefined) { + // Only fire the one-shot completion callback when we actually watched + // this task run and *then* finish during this page's lifetime. A task + // that is already terminal on first sight (prev === undefined) is + // stale -- its callback belonged to whoever was watching when it + // finished, and its effect is already applied server-side. Re-firing + // it on load is harmful for reload-style callbacks (func:'refresh'): + // /sub/tasks is an nchan channel that redelivers its retained message + // to every new subscriber, and taskPrev starts empty on each load, so + // the page reloads -> 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' && foregroundTaskId==t.id) { From de74806633ad02a03a78d43c9ad7c5d829ec8ab1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 8 Jul 2026 20:43:12 -0400 Subject: [PATCH 15/23] feat(task-tray): user-resizable task sheet width, persisted per browser The foreground task sheet (.sweet-alert.nchan) was a fixed 60rem. Add a right-edge grip to drag it wider; the width is remembered per browser and restored the next time the sheet opens. - Width is driven by a --task-modal-width CSS var scoped to .sweet-alert.nchan, not an inline style, so it never leaks onto the shared .sweet-alert node that other dialogs reuse (min(var, 90vw) keeps the 60rem default and viewport cap). - The sheet is centered (left:50% + translateX(-50%)), so its right edge tracks the pointer at width = 2*(pointerX - viewportCenterX); clamped to [~600, 90vw]. - Saved to localStorage on pointer release; re-applied on open via applyTaskModalWidth(). Grip hidden on mobile (sheet is fullscreen there). --- .../DefaultPageLayout/BodyInlineJS.php | 53 +++++++++++++++++++ .../plugins/dynamix/styles/default-base.css | 36 ++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index fe67904db9..c221419db0 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -202,6 +202,57 @@ function fireTaskCallback(t) { } } +// The foreground task sheet is horizontally resizable: drag the right-edge grip +// to widen it, and the choice is remembered per browser. Width is applied via the +// --task-modal-width CSS var (scoped to .sweet-alert.nchan in CSS) instead of an +// inline style, so it never leaks onto the shared .sweet-alert node that other +// dialogs (confirmations, etc.) reuse. Restored on open, saved on drag release. +var TASK_MODAL_WIDTH_KEY = 'unraid.taskModal.width'; +function taskModalWidthBounds() { + var cap = Math.round(window.innerWidth * 0.9); + return { min: Math.min(600, cap), max: Math.max(Math.min(600, cap), cap) }; // 600px == the 60rem default +} +function applyTaskModalWidth() { + var w = parseInt(localStorage.getItem(TASK_MODAL_WIDTH_KEY), 10); + if (!w) return; // no preference -> CSS default (60rem) + var b = taskModalWidthBounds(); + w = Math.max(b.min, Math.min(w, b.max)); + document.documentElement.style.setProperty('--task-modal-width', w + 'px'); +} +function ensureTaskModalResizer() { + var el = document.querySelector('.sweet-alert.nchan'); + if (!el || el.querySelector('.nchan-resize')) return; // singleton swal node: attach once + var handle = document.createElement('div'); + handle.className = 'nchan-resize'; + handle.title = ""; + el.appendChild(handle); + var dragging = false; + handle.addEventListener('pointerdown', function(e){ + dragging = true; + try { handle.setPointerCapture(e.pointerId); } catch(_){} + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + handle.addEventListener('pointermove', function(e){ + if (!dragging) return; + var b = taskModalWidthBounds(); + // .nchan is centered (left:50% + translateX(-50%)); its right edge tracks the + // pointer when width == 2 * (pointerX - viewportCenterX). + var w = Math.max(b.min, Math.min(Math.round(2 * (e.clientX - window.innerWidth / 2)), b.max)); + document.documentElement.style.setProperty('--task-modal-width', w + 'px'); + }); + function endDrag(e){ + if (!dragging) return; + dragging = false; + try { handle.releasePointerCapture(e.pointerId); } catch(_){} + document.body.style.userSelect = ''; + var w = parseInt(getComputedStyle(el).width, 10); // final rendered width + if (w) localStorage.setItem(TASK_MODAL_WIDTH_KEY, String(w)); // persist on release + } + handle.addEventListener('pointerup', endDrag); + handle.addEventListener('pointercancel', endDrag); +} + // bring a task to the foreground: open the modal, replay its server-side log, // then stream live if it is still running function foregroundTask(id) { @@ -236,6 +287,8 @@ function foregroundTask(id) { trayRender(); }); $('.sweet-alert').addClass('nchan').css('pointer-events',''); + applyTaskModalWidth(); + ensureTaskModalResizer(); // colored state strip between the title and the log (openDone/openError recolor it) $('.sweet-alert .nchan-state').remove(); $('.sweet-alert > h2').after("
"+stateHtml+"
"); diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 5b74ca9890..63398e4464 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2011,8 +2011,11 @@ label.checkbox input:disabled ~ .checkmark { .sweet-alert.nchan { display: flex !important; flex-direction: column; - width: 90vw; - max-width: 60rem; + /* user-resizable width: drag the right-edge grip to widen. The chosen width + is stored per browser in localStorage and re-applied via --task-modal-width + (see applyTaskModalWidth / ensureTaskModalResizer in BodyInlineJS). Defaults + to the 60rem sheet width, always capped to the viewport. */ + width: min(var(--task-modal-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; @@ -2137,6 +2140,34 @@ label.checkbox input:disabled ~ .checkmark { 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 (ensureTaskModalResizer 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) { @@ -2155,6 +2186,7 @@ label.checkbox input:disabled ~ .checkmark { 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; From 0d5aa9034e215df46f6666bba19991b91080bc4d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 8 Jul 2026 20:58:42 -0400 Subject: [PATCH 16/23] feat(task-tray): shared close control + resize across all .nchan sheets PR #2665 repurposed .nchan into the full-height sheet that hides the button bar when there's no confirm button. openChanges (Release Notes) and openAlert add .nchan but relied on a confirm button that isn't shown, so they rendered as sheets with no way to close (regression vs master, where .nchan was inert). Introduce decorateNchanSheet(): the single place every .nchan sheet gets its chrome -- the resize grip, the shared remembered width, and a corner close control. Task sheets minimize (keep the op in the tray); changelog/alert sheets close. foregroundTask, openChanges and openAlert all route through it, so the Release Notes/alert sheets are closable again and resizing is shared across all of them (one --nchan-sheet-width preference). Also scope .nchan-close (like .nchan-resize) to .nchan so a control left on the shared swal node never shows on an ordinary dialog. Renamed the width helpers/ var/localStorage key from task-modal-* to nchan-sheet-* to reflect the shared scope. --- .../DefaultPageLayout/BodyInlineJS.php | 72 +++++++++++-------- .../DefaultPageLayout/HeadInlineJS.php | 2 + .../plugins/dynamix/styles/default-base.css | 15 ++-- 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index c221419db0..5d3b4be6b9 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -202,24 +202,28 @@ function fireTaskCallback(t) { } } -// The foreground task sheet is horizontally resizable: drag the right-edge grip -// to widen it, and the choice is remembered per browser. Width is applied via the -// --task-modal-width CSS var (scoped to .sweet-alert.nchan in CSS) instead of an -// inline style, so it never leaks onto the shared .sweet-alert node that other -// dialogs (confirmations, etc.) reuse. Restored on open, saved on drag release. -var TASK_MODAL_WIDTH_KEY = 'unraid.taskModal.width'; -function taskModalWidthBounds() { +// Shared chrome for every .nchan "sheet" dialog -- the foreground task modal, the +// changelog / Release Notes viewer (openChanges), and alert prompts (openAlert). +// Each sheet gets a resizable, width-remembering right edge and a corner close +// control, so they look and behave the same and none can end up un-closable. +// +// Width is applied via the --nchan-sheet-width CSS var (scoped to +// .sweet-alert.nchan in CSS) rather than an inline style, so it never leaks onto +// the shared .sweet-alert node that ordinary dialogs reuse. One width preference +// is shared across all sheets: resize any sheet and they all remember it. +var NCHAN_SHEET_WIDTH_KEY = 'unraid.nchanSheet.width'; +function nchanSheetWidthBounds() { var cap = Math.round(window.innerWidth * 0.9); return { min: Math.min(600, cap), max: Math.max(Math.min(600, cap), cap) }; // 600px == the 60rem default } -function applyTaskModalWidth() { - var w = parseInt(localStorage.getItem(TASK_MODAL_WIDTH_KEY), 10); +function applyNchanSheetWidth() { + var w = parseInt(localStorage.getItem(NCHAN_SHEET_WIDTH_KEY), 10); if (!w) return; // no preference -> CSS default (60rem) - var b = taskModalWidthBounds(); + var b = nchanSheetWidthBounds(); w = Math.max(b.min, Math.min(w, b.max)); - document.documentElement.style.setProperty('--task-modal-width', w + 'px'); + document.documentElement.style.setProperty('--nchan-sheet-width', w + 'px'); } -function ensureTaskModalResizer() { +function ensureNchanResizer() { var el = document.querySelector('.sweet-alert.nchan'); if (!el || el.querySelector('.nchan-resize')) return; // singleton swal node: attach once var handle = document.createElement('div'); @@ -235,11 +239,11 @@ function ensureTaskModalResizer() { }); handle.addEventListener('pointermove', function(e){ if (!dragging) return; - var b = taskModalWidthBounds(); + var b = nchanSheetWidthBounds(); // .nchan is centered (left:50% + translateX(-50%)); its right edge tracks the // pointer when width == 2 * (pointerX - viewportCenterX). var w = Math.max(b.min, Math.min(Math.round(2 * (e.clientX - window.innerWidth / 2)), b.max)); - document.documentElement.style.setProperty('--task-modal-width', w + 'px'); + document.documentElement.style.setProperty('--nchan-sheet-width', w + 'px'); }); function endDrag(e){ if (!dragging) return; @@ -247,11 +251,29 @@ function endDrag(e){ try { handle.releasePointerCapture(e.pointerId); } catch(_){} document.body.style.userSelect = ''; var w = parseInt(getComputedStyle(el).width, 10); // final rendered width - if (w) localStorage.setItem(TASK_MODAL_WIDTH_KEY, String(w)); // persist on release + if (w) localStorage.setItem(NCHAN_SHEET_WIDTH_KEY, String(w)); // persist on release } handle.addEventListener('pointerup', endDrag); handle.addEventListener('pointercancel', endDrag); } +// Give the current .nchan sheet its shared chrome (resize grip + close control). +// opts.close: +// 'minimize' -> corner control tucks the sheet away but keeps the task in the +// tray, so a running op is never killed (foreground task sheets) +// 'dismiss' -> (default) corner control just closes the dialog +// opts.tip overrides the control's tooltip. +function decorateNchanSheet(opts) { + opts = opts || {}; + if (!document.querySelector('.sweet-alert.nchan')) return; + applyNchanSheetWidth(); + ensureNchanResizer(); + var minimize = opts.close === 'minimize'; + var onclick = minimize ? 'minimizeForegroundTask()' : 'nchanCloseModal(true)'; + var icon = minimize ? 'fa-minus' : 'fa-times'; + var tip = opts.tip || (minimize ? "" : ""); + $('.sweet-alert .nchan-close').remove(); + $('.sweet-alert').append(""); +} // bring a task to the foreground: open the modal, replay its server-side log, // then stream live if it is still running @@ -287,23 +309,15 @@ function foregroundTask(id) { trayRender(); }); $('.sweet-alert').addClass('nchan').css('pointer-events',''); - applyTaskModalWidth(); - ensureTaskModalResizer(); // 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 ? "" : ""; - $('.sweet-alert .nchan-close').remove(); - $('.sweet-alert').append(""); + // The corner control is ALWAYS minimize: it tucks the sheet away but keeps the + // task running in the tray; removal is the separate Dismiss button. openDone/ + // openError swap the tooltip to the finished form. decorateNchanSheet adds this + // control plus the shared resize grip and restores the remembered width. + var closeTip = finished ? "" : ""; + decorateNchanSheet({ close:'minimize', tip: closeTip }); $('pre#swaltext').html(''); $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata){ if (foregroundTaskId!==id) return; // user moved on while loading diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 37e5569d31..7f18579645 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -229,6 +229,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("").prop('disabled',false).show(); }); @@ -241,6 +242,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); }); } diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 63398e4464..12674aa3c7 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2012,10 +2012,10 @@ label.checkbox input:disabled ~ .checkmark { 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 --task-modal-width - (see applyTaskModalWidth / ensureTaskModalResizer in BodyInlineJS). Defaults - to the 60rem sheet width, always capped to the viewport. */ - width: min(var(--task-modal-width, 60rem), 90vw); + 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; @@ -2113,8 +2113,11 @@ label.checkbox input:disabled ~ .checkmark { 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 */ +/* 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; From 5db13addaad94f6b4937055c09f004caf3e7cfbe Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 14 Jul 2026 15:56:55 -0400 Subject: [PATCH 17/23] fix(task-tray): clear stale state before ordinary dialogs --- .../include/DefaultPageLayout/BodyInlineJS.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 5d3b4be6b9..f8c88291fa 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -468,10 +468,17 @@ function trayRender() { // 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'); + var $sa = $('.sweet-alert'); + $sa.css('pointer-events','none'); if (doClose && typeof swal!=='undefined' && swal.close) swal.close(); + // SweetAlert reuses the same .sweet-alert node for every dialog and only + // replaces its built-in title/body/buttons. The task sheet inserts this + // state row itself, so leaving it behind makes the next ordinary warning + // (for example Abort or plugin uninstall) inherit stale In Progress / + // Finished content. Remove task-owned state as part of the close handoff; + // keep the .nchan class until the fade completes to avoid the old close flash. + $sa.children('.nchan-state').remove(); setTimeout(function(){ - var $sa = $('.sweet-alert'); $sa.css('pointer-events',''); if (!foregroundTaskId) $sa.removeClass('nchan'); }, 350); From b7b768629c53b33810c8f4c6831019feddac9e4f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Jul 2026 10:59:07 -0400 Subject: [PATCH 18/23] fix(task-queue): abort kills the whole process group, not just the wrapper QA found that aborting a tracked op (e.g. Docker Force Update) marked the task error but let the underlying operation run to completion. task_launch spawned the op with 'nohup bash -c', so the wrapper shell stayed in php-fpm's process group; abort's 'kill ' terminated only that shell and orphaned the real worker (update_container -> docker pull/run), which finished seconds later. Launch each task under setsid so it owns a session + process group (pid==pgid), and have abort signal the whole group ('kill -TERM -') to stop the command and every child it spawned. The bare-pid kill remains as a fallback. --- emhttp/plugins/dynamix/include/TaskCommand.php | 9 ++++++++- emhttp/plugins/dynamix/include/TaskQueue.php | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php index 378dd88d09..e9c43eaf2f 100644 --- a/emhttp/plugins/dynamix/include/TaskCommand.php +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -36,7 +36,14 @@ $task = task_read($id); if ($task) { if ($task['status']==='running' && ctype_digit((string)$task['pid']) && (int)$task['pid'] > 1) { - exec('kill '.escapeshellarg($task['pid'])); + $pid = (int)$task['pid']; + // The task runs in its own session/process group (task_launch uses setsid), + // so signal the whole group with a negative pid to stop the underlying + // operation AND every child it spawned. Killing only the wrapper's pid left + // the real worker (e.g. a docker update) orphaned and running to completion. + // The bare-pid kill is a fallback in case the group was never established. + exec('kill -TERM -'.$pid.' 2>/dev/null'); + exec('kill -TERM '.$pid.' 2>/dev/null'); foreach (glob('/tmp/plugins/pluginPending/*') ?: [] as $file) @unlink($file); $task['status'] = 'error'; $task['finished'] = time(); diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index 2f4af7ce47..4272bc3995 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -135,7 +135,13 @@ function task_launch(&$task) { // 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.$stamp; - $pid = exec($env.'nohup bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 & echo $!'); + // 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. + $pid = exec($env.'setsid bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 & echo $!'); $task['pid'] = $pid; $task['status'] = 'running'; $task['started'] = time(); From 42eae71c8d98bb58ccee93c2020d4c7091227630 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Jul 2026 15:08:21 -0400 Subject: [PATCH 19/23] fix(task-tray): empty-plg callbacks, per-task output channel, replay/live dedupe Three QA findings against the shared task tray: 1. Completion callbacks with an empty plugin identifier never fired. The legacy modal fired (func||'loadlist')(plg) whenever plg was non-null, so Apps.page openPlugin(...,'','refresh'), CA's ca_openPlugin(...,'','OpenSidebarAndRefreshDisplay'), Tailscale install and Remove Selected Plugins all refreshed after the op. fireTaskCallback required a truthy plg, dropping every one of those: installing Community Apps left /Apps on the pre-install screen until a manual reload. Fire the callback whenever one was requested (plg or func non-empty); ':return' still suppresses it. 2. A running task inherited a stale Finished state. The modal streamed from the shared /sub/ channel, which does not identify the originating task and retains its last message forever (generic /pub/ channels use nchan_message_timeout 0): foregrounding a new running task consumed the PREVIOUS task's retained _DONE_ and flipped to Finished mid-run. 3. The replay/live handoff duplicated one record: the retained last message re-delivered on subscribe was already rendered by the log replay (30 server records -> 31 rows). publish.php now mirrors every captured message onto the task's own channel (task-) tagged with the record's byte offset in the task log, taken under the log lock. The modal subscribes to /sub/task- instead of the shared type channel, so cross-task leakage (2) is impossible, and it drops any live message whose offset falls inside the byte range the log replay covered -- TaskCommand's log action reports that range as X-Task-Log-Size, read under the shared lock -- making the handoff exact (3). The shared type channels are still published unchanged for external subscribers, and task_delete drops the mirrored channel so retained buffers don't accumulate in nchan shared memory. --- .../DefaultPageLayout/BodyInlineJS.php | 100 ++++++++++++------ .../plugins/dynamix/include/TaskCommand.php | 21 +++- emhttp/plugins/dynamix/include/TaskQueue.php | 16 +++ emhttp/plugins/dynamix/include/publish.php | 27 ++++- 4 files changed, 127 insertions(+), 37 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index f8c88291fa..201a424baa 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -94,20 +94,15 @@ function wlanSettings() { // Multi-task background operation system (shared across clients/subsystems) // --------------------------------------------------------------------------- // The backend (TaskQueue.php + the `tasks` daemon) owns the queue: at most one -// RUNNING op per type, so the live /sub/ channels never interleave. The -// full task list is broadcast on /sub/tasks; per-task output is captured to a -// server-side log and replayed when a task is brought to the foreground. +// RUNNING op per type, so the legacy shared /sub/ channels never +// interleave. The full task list is broadcast on /sub/tasks; per-task output +// is captured to a server-side log (replayed on foreground) and mirrored on a +// per-task channel /sub/task- that the foreground modal streams from. // =========================================================================== -var nchan_plugins = new NchanSubscriber('/sub/plugins',{subscriber:'websocket', reconnectTimeout:5000}); -var nchan_docker = new NchanSubscriber('/sub/docker',{subscriber:'websocket', reconnectTimeout:5000}); -var nchan_vmaction = new NchanSubscriber('/sub/vmaction',{subscriber:'websocket', reconnectTimeout:5000}); -const nchanByType = {plugins:nchan_plugins, docker:nchan_docker, vmaction:nchan_vmaction}; - const TASK_ENDPOINT = '/plugins/dynamix/include/TaskCommand.php'; var taskList = []; const taskPrev = {}; var foregroundTaskId = null; -var foregroundType = null; function taskById(id) { for (var i=0;i').text(s==null?'':String(s)).html(); // a no-op start/stop can't raise and abort the surrounding handler. function nchanStart(sub){ try { if (sub && !sub.running) sub.start(); } catch(e) {} } function nchanStop(sub) { try { if (sub && sub.running) sub.stop(); } catch(e) {} } -function stopAllTypeChannels(){ nchanStop(nchan_plugins); nchanStop(nchan_docker); nchanStop(nchan_vmaction); } + +// Live output for the foreground modal comes from the task's OWN channel +// (/sub/task-), not the shared per-type channels. publish.php mirrors every +// captured message there, prefixed with the message's byte offset in the task +// log ("\x1f"). Two problems with the shared /sub/ +// channels made them unusable for the modal: +// - they don't identify the originating task, so nchan's retained message +// (generic /pub/ channels never expire) leaks across tasks: a new running +// task's modal would consume the PREVIOUS task's retained _DONE_ and flip +// to Finished while the new op was still running +// - the retained last message re-delivered on subscribe duplicated the last +// record already rendered by the log replay (N records -> N+1 rows) +// The offset tag solves the second exactly: the log replay reports how many +// bytes it covered (X-Task-Log-Size) and every live message whose offset falls +// inside that range is already on screen, so it's dropped. The shared type +// channels are still published for any external subscriber. +var taskSub = null, taskSubId = null, replayedBytes = 0; +function startTaskChannel(id) { + if (taskSub && taskSubId === id) return; + stopTaskChannel(); + taskSubId = id; + taskSub = new NchanSubscriber('/sub/task-'+id,{subscriber:'websocket', reconnectTimeout:5000}); + taskSub.on('message', function(data){ routeTaskMessage(id, data); }); + nchanStart(taskSub); +} +function stopTaskChannel() { + if (taskSub) { nchanStop(taskSub); taskSub = null; taskSubId = null; } +} // progress_dots / progress_span (declared in HeadInlineJS) are global wait-spinner // timers keyed by element id. Clear them when (re)opening or closing a modal so a @@ -181,22 +203,31 @@ function renderMessage(type, data) { box.scrollTop(box[0].scrollHeight); } -// live channel messages render only into the foregrounded task's modal -function routeMessage(type, data) { - if (!data) return; - if (data=='_DONE_' || data=='_ERROR_') { - if (foregroundTaskId && foregroundType==type) { if (data=='_ERROR_') openError(data); else openDone(data); } - return; +// live per-task channel messages render only into the foregrounded task's modal +function routeTaskMessage(id, raw) { + if (foregroundTaskId !== id || !raw) return; + var data = raw, sep = raw.indexOf('\x1f'); + // strip the "\x1f" tag; drop anything the replay already covered + if (sep > 0 && /^\d+$/.test(raw.slice(0,sep))) { + if (parseInt(raw.slice(0,sep),10) < replayedBytes) return; + data = raw.slice(sep+1); } - if (foregroundTaskId && foregroundType==type) renderMessage(type, data); + if (!data) return; + if (data=='_DONE_') { openDone(data); return; } + if (data=='_ERROR_') { openError(data); return; } + var t = taskById(id); + renderMessage(t ? t.type : 'plugins', data); } -nchan_plugins.on('message', function(data){ routeMessage('plugins', data); }); -nchan_docker.on('message', function(data){ routeMessage('docker', data); }); -nchan_vmaction.on('message', function(data){ routeMessage('vmaction', data); }); -// legacy per-op reload callback ( (func||'loadlist')(plg) ), suppressed for ':return' +// Legacy per-op reload callback ( (func||'loadlist')(plg) ), suppressed for +// ':return'. The legacy modal fired this whenever plg was non-null, so an +// empty plg with an explicit func (e.g. Apps.page openPlugin(...,'','refresh'), +// CA's ca_openPlugin(...,'','OpenSidebarAndRefreshDisplay')) still refreshed +// the page. Task records serialize plg/func as strings, so "a callback was +// requested" means either field is non-empty -- requiring a truthy plg alone +// silently dropped every empty-identifier refresh. function fireTaskCallback(t) { - if (t && t.plg && t.plg != ':return') { + if (t && t.plg != ':return' && (t.plg || t.func)) { var fn = window[t.func || 'loadlist']; if (typeof fn === 'function') setTimeout(function(){ fn(t.plg); },250); } @@ -281,8 +312,7 @@ function foregroundTask(id) { var task = taskById(id); if (!task) return; foregroundTaskId = id; - foregroundType = task.type; - stopAllTypeChannels(); + stopTaskChannel(); clearProgressDots(); // 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 @@ -300,8 +330,8 @@ function foregroundTask(id) { : " "; swal({title:escapeTaskHtml(task.title),text:"

",html:true,animation:'none',showConfirmButton:finished,confirmButtonText:""},function(close){ // confirm/Dismiss (or Esc): background while running, clear once finished - if (foregroundTaskId===id) { foregroundTaskId=null; foregroundType=null; } - stopAllTypeChannels(); + if (foregroundTaskId===id) foregroundTaskId=null; + stopTaskChannel(); clearProgressDots(); var fresh = taskById(id); if (fresh && (fresh.status=='done'||fresh.status=='error')) { fireTaskCallback(fresh); dismissTask(id); } @@ -319,8 +349,11 @@ function foregroundTask(id) { var closeTip = finished ? "" : ""; decorateNchanSheet({ close:'minimize', tip: closeTip }); $('pre#swaltext').html(''); - $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata){ + $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata,_st,xhr){ if (foregroundTaskId!==id) return; // user moved on while loading + // byte length of the log as served: live messages tagged with an offset + // below this were rendered by this replay (see routeTaskMessage) + replayedBytes = parseInt(xhr && xhr.getResponseHeader('X-Task-Log-Size'),10) || 0; var msgs = (logdata||'').split('\x1e'); for (var i=0;i "); - nchanStart(nchanByType[t.type]); + startTaskChannel(t.id); } } taskPrev[t.id] = t.status; @@ -485,8 +521,8 @@ function nchanCloseModal(doClose) { } function minimizeForegroundTask() { - if (foregroundTaskId) { foregroundTaskId=null; foregroundType=null; } - stopAllTypeChannels(); + foregroundTaskId=null; + stopTaskChannel(); clearProgressDots(); nchanCloseModal(true); trayRender(); diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php index e9c43eaf2f..7cb0a4ed24 100644 --- a/emhttp/plugins/dynamix/include/TaskCommand.php +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -72,10 +72,25 @@ die(); case 'log': - // output captured so far, for foreground replay + // 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 publish.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 (publish.php appends under the + // exclusive one) so the length always lands on a record boundary. header('Content-Type: text/plain'); - if (task_valid_id($id) && is_file(task_log($id))) readfile(task_log($id)); - die(); + $data = ''; + if (task_valid_id($id) && is_file(task_log($id))) { + $fh = @fopen(task_log($id), 'rb'); + if ($fh) { + @flock($fh, LOCK_SH); + $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'); diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index 4272bc3995..1fd96058a9 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -65,6 +65,22 @@ function task_write($task) { 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 publish.php) 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) { + $com = curl_init("http://localhost/pub/task-$id"); + 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) diff --git a/emhttp/plugins/dynamix/include/publish.php b/emhttp/plugins/dynamix/include/publish.php index 8168acc377..44f080782b 100755 --- a/emhttp/plugins/dynamix/include/publish.php +++ b/emhttp/plugins/dynamix/include/publish.php @@ -37,9 +37,32 @@ function publish($endpoint, $message, $len=1, $abort=false, $abortTime=30) { // message to the task's log so it can be replayed when the task is brought // back to the foreground. Messages are delimited by RS (\x1e) to preserve // boundaries even when a message itself contains newlines. + // + // Each captured message is also mirrored onto the task's own channel + // (task-) as "\x1f". The foreground modal + // streams from that channel instead of the shared per-type one, so output + // can never be misattributed across tasks (a retained _DONE_ from an earlier + // op must not finish the next one), and the offset lets the client drop any + // live message the log replay already rendered (X-Task-Log-Size in + // TaskCommand.php) instead of duplicating it at the replay/live boundary. + // The size-then-append is done under LOCK_EX and the log reader takes + // LOCK_SH, so offsets are race-free and reads land on record boundaries. + // A buffer of 10 lets a subscriber joining mid-stream pick up a small + // backlog (dedupe makes redelivery harmless). The endpoint-prefix guard + // keeps the mirror publish itself from being captured again. $taskId = getenv('NCHAN_TASK'); - if ($taskId !== false && $taskId !== '' && ctype_xdigit($taskId)) { - @file_put_contents("/var/local/emhttp/tasks/$taskId.log", $message."\x1e", FILE_APPEND); + if ($taskId !== false && $taskId !== '' && ctype_xdigit($taskId) && strncmp($endpoint,'task-',5) !== 0) { + $fh = @fopen("/var/local/emhttp/tasks/$taskId.log", 'c'); + if ($fh) { + @flock($fh, LOCK_EX); + fseek($fh, 0, SEEK_END); + $offset = ftell($fh); + fwrite($fh, $message."\x1e"); + fflush($fh); + @flock($fh, LOCK_UN); + fclose($fh); + publish("task-$taskId", $offset."\x1f".$message, 10); + } } if ( is_file("/tmp/publishPaused") ) From 0b7e2805733bb3cf5d72f96a59c7c2192db21c15 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Jul 2026 15:39:17 -0400 Subject: [PATCH 20/23] fix(task-tray): nchan channel DELETE requires the buffer_length arg Live QA on DGTest03 showed task_channel_delete was a no-op: the generic /pub/ location sets nchan_message_buffer_length $arg_buffer_length, and nginx errors out ('missing nchan_message_buffer_length value') before nchan processes ANY method when the arg is absent -- including DELETE. Pass buffer_length=1; with it, DELETE returns 200 and the channel's retained messages are dropped (verified live: message count 9 -> channel 404 after dismiss). --- emhttp/plugins/dynamix/include/TaskQueue.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index 1fd96058a9..c7f9a86c09 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -73,7 +73,10 @@ function task_delete($id) { // 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) { - $com = curl_init("http://localhost/pub/task-$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', From 91eead46883cc5e8d6335995d207ab49ef146354 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 3 Aug 2026 13:17:26 -0400 Subject: [PATCH 21/23] fix(task-tray): capture output from legacy publishers --- .../DefaultPageLayout/BodyInlineJS.php | 8 ++- .../plugins/dynamix/include/TaskCapture.php | 37 +++++++++++ .../plugins/dynamix/include/TaskCommand.php | 9 +-- emhttp/plugins/dynamix/include/TaskQueue.php | 63 +++++++++++++++---- emhttp/plugins/dynamix/include/publish.php | 32 ---------- etc/rc.d/rc.nginx | 24 +++++++ 6 files changed, 123 insertions(+), 50 deletions(-) create mode 100644 emhttp/plugins/dynamix/include/TaskCapture.php diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 201a424baa..d58e737c50 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -98,6 +98,8 @@ function wlanSettings() { // interleave. The full task list is broadcast on /sub/tasks; per-task output // is captured to a server-side log (replayed on foreground) and mirrored on a // per-task channel /sub/task- that the foreground modal streams from. +// Capture occurs at Nginx's shared publisher boundary, so direct legacy +// publishers and WebGUI's publish() helper follow the same task-owned path. // =========================================================================== const TASK_ENDPOINT = '/plugins/dynamix/include/TaskCommand.php'; var taskList = []; @@ -115,9 +117,9 @@ function nchanStart(sub){ try { if (sub && !sub.running) sub.start(); } catch(e) function nchanStop(sub) { try { if (sub && sub.running) sub.stop(); } catch(e) {} } // Live output for the foreground modal comes from the task's OWN channel -// (/sub/task-), not the shared per-type channels. publish.php mirrors every -// captured message there, prefixed with the message's byte offset in the task -// log ("\x1f"). Two problems with the shared /sub/ +// (/sub/task-), not the shared per-type channels. task_capture() mirrors +// every captured message there, prefixed with the message's byte offset in the +// task log ("\x1f"). Two problems with the shared /sub/ // channels made them unusable for the modal: // - they don't identify the originating task, so nchan's retained message // (generic /pub/ channels never expire) leaks across tasks: a new running 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 @@ + + diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php index 7cb0a4ed24..4e1ddd530d 100644 --- a/emhttp/plugins/dynamix/include/TaskCommand.php +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -74,10 +74,11 @@ 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 publish.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 (publish.php appends under the - // exclusive one) so the length always lands on a record boundary. + // 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))) { diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index c7f9a86c09..2215ebb805 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -15,9 +15,10 @@ * 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 (written by publish.php when the - * NCHAN_TASK env var is set). The full task list is broadcast to all clients - * on the `tasks` nchan channel whenever it changes. + * 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 RUNNING task per type at any time, so the * existing shared live channels (/sub/plugins, /sub/docker, /sub/vmaction) @@ -68,7 +69,8 @@ function task_delete($id) { task_channel_delete($id); } -// Drop the task's mirrored nchan channel (see publish.php) along with the task. +// 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. @@ -111,6 +113,42 @@ function task_running_type($type) { 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 running task per type, which makes the shared +// channel -> task association unambiguous. +// +// 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; + @flock($fh, LOCK_EX); + 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); + @flock($fh, LOCK_UN); + fclose($fh); + if (!$complete) return false; + + // A small retained buffer lets a foreground subscriber joining mid-stream + // catch up; byte offsets make any redelivery harmless. + publish("task-{$task['id']}", $offset."\x1f".$message, 10); + return true; +} + // resolve a command to an absolute script path the same way StartCommand.php does function task_resolve($cmd) { global $docroot; @@ -124,7 +162,7 @@ function task_resolve($cmd) { return [$name, $args]; } -// launch a task in the background, capturing its output to .log via NCHAN_TASK +// 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 @@ -139,15 +177,18 @@ function task_launch(&$task) { [$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 for the stamp so its `tasks` broadcast isn't captured into this - // task's foreground-replay log. The daemon stays a fallback for the case where - // the process is hard-killed before the stamp can run. + // 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 @@ -186,9 +227,9 @@ function task_advance($type) { // 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. +// 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; diff --git a/emhttp/plugins/dynamix/include/publish.php b/emhttp/plugins/dynamix/include/publish.php index 44f080782b..b50ff02865 100755 --- a/emhttp/plugins/dynamix/include/publish.php +++ b/emhttp/plugins/dynamix/include/publish.php @@ -33,38 +33,6 @@ function curl_socket($socket, $url, $message='') { function publish($endpoint, $message, $len=1, $abort=false, $abortTime=30) { static $abortStart = [], $com = [], $lens = []; - // When launched by the task queue (TaskQueue.php), capture every published - // message to the task's log so it can be replayed when the task is brought - // back to the foreground. Messages are delimited by RS (\x1e) to preserve - // boundaries even when a message itself contains newlines. - // - // Each captured message is also mirrored onto the task's own channel - // (task-) as "\x1f". The foreground modal - // streams from that channel instead of the shared per-type one, so output - // can never be misattributed across tasks (a retained _DONE_ from an earlier - // op must not finish the next one), and the offset lets the client drop any - // live message the log replay already rendered (X-Task-Log-Size in - // TaskCommand.php) instead of duplicating it at the replay/live boundary. - // The size-then-append is done under LOCK_EX and the log reader takes - // LOCK_SH, so offsets are race-free and reads land on record boundaries. - // A buffer of 10 lets a subscriber joining mid-stream pick up a small - // backlog (dedupe makes redelivery harmless). The endpoint-prefix guard - // keeps the mirror publish itself from being captured again. - $taskId = getenv('NCHAN_TASK'); - if ($taskId !== false && $taskId !== '' && ctype_xdigit($taskId) && strncmp($endpoint,'task-',5) !== 0) { - $fh = @fopen("/var/local/emhttp/tasks/$taskId.log", 'c'); - if ($fh) { - @flock($fh, LOCK_EX); - fseek($fh, 0, SEEK_END); - $offset = ftell($fh); - fwrite($fh, $message."\x1e"); - fflush($fh); - @flock($fh, LOCK_UN); - fclose($fh); - publish("task-$taskId", $offset."\x1f".$message, 10); - } - } - if ( is_file("/tmp/publishPaused") ) return false; 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"; From e0007423d5d5942a98c5808a88549641633aae9e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 3 Aug 2026 13:51:45 -0400 Subject: [PATCH 22/23] fix(task-tray): harden lifecycle and replay handoff --- .codex/coderabbit-fixes-wip.md | 60 ----- .../dynamix.plugin.manager/Plugins.page | 4 +- .../include/PluginHelpers.php | 4 +- .../DefaultPageLayout/BodyInlineJS.php | 101 ++++++-- .../DefaultPageLayout/HeadInlineJS.php | 7 +- .../plugins/dynamix/include/TaskCommand.php | 53 ++-- emhttp/plugins/dynamix/include/TaskQueue.php | 243 ++++++++++++++---- emhttp/plugins/dynamix/nchan/tasks | 47 +++- .../plugins/dynamix/styles/default-base.css | 4 +- 9 files changed, 355 insertions(+), 168 deletions(-) delete mode 100644 .codex/coderabbit-fixes-wip.md diff --git a/.codex/coderabbit-fixes-wip.md b/.codex/coderabbit-fixes-wip.md deleted file mode 100644 index b24ef5b01f..0000000000 --- a/.codex/coderabbit-fixes-wip.md +++ /dev/null @@ -1,60 +0,0 @@ -# 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 e56f9c3531..f98e82ed8b 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -206,7 +206,7 @@ function loadlist(id,check) { }); } // Row buttons are rendered server-side (make_link) from the task queue: a plugin -// with a running/queued task shows "Upgrading" (disabled). Refresh the rows when +// with an active/queued task shows "Upgrading" (disabled). Refresh the rows when // the set of active plugin tasks changes, so that state appears/clears live — // loadlist(null,1) re-renders just the status cells (no network re-check, no // flickery full reload). The task queue is the single source of truth. @@ -216,7 +216,7 @@ function onPluginTasksChanged() { if (typeof taskList !== 'undefined') { for (var i=0;i\x1f" tag; drop anything the replay already covered if (sep > 0 && /^\d+$/.test(raw.slice(0,sep))) { @@ -221,6 +229,40 @@ function routeTaskMessage(id, raw) { renderMessage(t ? t.type : 'plugins', data); } +function loadTaskReplay(id, task, reset) { + var request = ++taskReplayRequest; + $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata,_st,xhr){ + if (foregroundTaskId!==id || request!==taskReplayRequest) return; + if (reset) $('pre#swaltext').html(''); + replayedBytes = parseInt(xhr && xhr.getResponseHeader('X-Task-Log-Size'),10) || 0; + var msgs = (logdata||'').split('\x1e'); + for (var i=0;i0 ? parseInt(a.slice(0,as),10) : Number.MAX_VALUE; + var bo = bs>0 ? parseInt(b.slice(0,bs),10) : Number.MAX_VALUE; + return ao-bo; + }); + var pending = taskPendingMessages; + taskPendingMessages = []; + taskReplayReady = true; + for (var j=0;j"); } -// bring a task to the foreground: open the modal, replay its server-side log, -// then stream live if it is still running +// bring a task to the foreground: establish the live subscriber first, buffer +// offset-tagged messages while replaying the durable log, then drain anything +// newer than that snapshot. Waiting for the subscriber's connect event closes +// the replay/live loss window even when a burst exceeds nchan's retained buffer. function foregroundTask(id) { var task = taskById(id); if (!task) return; @@ -329,6 +376,7 @@ function foregroundTask(id) { : task.status=='error' ? 'nchan-error' : 'nchan-running'; var stateHtml = task.status=='done' ? " " : task.status=='error' ? " " + : task.status=='aborting' ? " " : " "; swal({title:escapeTaskHtml(task.title),text:"

",html:true,animation:'none',showConfirmButton:finished,confirmButtonText:""},function(close){ // confirm/Dismiss (or Esc): background while running, clear once finished @@ -348,25 +396,26 @@ function foregroundTask(id) { // task running in the tray; removal is the separate Dismiss button. openDone/ // openError swap the tooltip to the finished form. decorateNchanSheet adds this // control plus the shared resize grip and restores the remembered width. - var closeTip = finished ? "" : ""; + var closeTip = finished || task.status=='aborting' ? "" : ""; decorateNchanSheet({ close:'minimize', tip: closeTip }); $('pre#swaltext').html(''); - $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata,_st,xhr){ - if (foregroundTaskId!==id) return; // user moved on while loading - // byte length of the log as served: live messages tagged with an offset - // below this were rendered by this replay (see routeTaskMessage) - replayedBytes = parseInt(xhr && xhr.getResponseHeader('X-Task-Log-Size'),10) || 0; - var msgs = (logdata||'').split('\x1e'); - for (var i=0;i "); + } else if ((t.status=='running' || t.status=='aborting') && foregroundTaskId==t.id) { + var activeText = t.status=='aborting' ? "" : ""; + $('#pluginProgressTitle').attr('class','nchan-state nchan-running').html(" "+activeText); startTaskChannel(t.id); } } @@ -443,6 +493,9 @@ function trayRender() { if (t.status=='running') { icon = ""; actions = show + "\">"; + } else if (t.status=='aborting') { + icon = "\">"; + actions = show; } else if (t.status=='queued') { icon = ""; actions = "\">"; diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 7f18579645..5ee628f1c1 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -175,7 +175,8 @@ function bannerAlert() {} // 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 : show/hide the CLOSE button (per-type meaning preserved downstream) +// 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); } @@ -188,6 +189,9 @@ function createTask(type,cmd,title,plg,func,start,button) { },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 @@ -198,6 +202,7 @@ function createTask(type,cmd,title,plg,func,start,button) { 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(); } foregroundTask(res.id); diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php index 4e1ddd530d..67c0799834 100644 --- a/emhttp/plugins/dynamix/include/TaskCommand.php +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -11,12 +11,18 @@ */ ?> 1) { - $pid = (int)$task['pid']; - // The task runs in its own session/process group (task_launch uses setsid), - // so signal the whole group with a negative pid to stop the underlying - // operation AND every child it spawned. Killing only the wrapper's pid left - // the real worker (e.g. a docker update) orphaned and running to completion. - // The bare-pid kill is a fallback in case the group was never established. - exec('kill -TERM -'.$pid.' 2>/dev/null'); - exec('kill -TERM '.$pid.' 2>/dev/null'); + $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); - $task['status'] = 'error'; - $task['finished'] = time(); - task_write($task); - task_advance($task['type']); - } else { - // queued (or already finished) task: just drop it + } 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(); @@ -84,9 +96,14 @@ if (task_valid_id($id) && is_file(task_log($id))) { $fh = @fopen(task_log($id), 'rb'); if ($fh) { - @flock($fh, LOCK_SH); + 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); + flock($fh, LOCK_UN); fclose($fh); } } diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index 2215ebb805..b96a47a494 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -20,10 +20,10 @@ * 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 RUNNING task per type at any time, so the - * existing shared live channels (/sub/plugins, /sub/docker, /sub/vmaction) - * never have two concurrent publishers. Additional same-type operations are - * queued and auto-started by the `tasks` daemon when the running one finishes. + * 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'); @@ -35,6 +35,7 @@ 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 @@ -63,6 +64,112 @@ function task_write($task) { 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)); @@ -109,15 +216,15 @@ function task_publish() { // the single running task of a type, or null function task_running_type($type) { foreach (task_list() as $t) - if ($t['type']===$type && $t['status']==='running') return $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 running task per type, which makes the shared -// channel -> task association unambiguous. +// 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 @@ -131,7 +238,11 @@ function task_capture($type, $message) { $fh = @fopen(task_log($task['id']), 'c'); if (!$fh) return false; - @flock($fh, LOCK_EX); + 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"; @@ -139,13 +250,19 @@ function task_capture($type, $message) { $complete = $written === strlen($record); if (!$complete && $offset !== false) ftruncate($fh, $offset); fflush($fh); - @flock($fh, LOCK_UN); - fclose($fh); - if (!$complete) return false; + 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. + // 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; } @@ -171,7 +288,7 @@ function task_launch(&$task) { if (!$resolved) { $task['status'] = 'error'; $task['finished'] = time(); - task_write($task); + if (!task_write($task)) @unlink(task_path($task['id'])); return false; } [$name,$args] = $resolved; @@ -194,35 +311,68 @@ function task_launch(&$task) { // 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.$stamp; + // 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. - $pid = exec($env.'setsid bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 & echo $!'); + 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(); - task_write($task); + 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 = fopen(task_dir()."/.$type.lock", 'c'); - if ($lock) flock($lock, LOCK_EX); - if (!task_running_type($type)) { - foreach (task_list() as $t) { - if ($t['type']===$type && $t['status']==='queued') { task_launch($t); break; } - } - } - if ($lock) { flock($lock, LOCK_UN); fclose($lock); } + $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_ @@ -244,26 +394,26 @@ function task_log_has_error($id) { // 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 is done under the per-type lock; the lock is then -// released before advancing so the (also-locking) task_advance can't deadlock on -// the same handle. An already-finalized task (e.g. aborted via TaskCommand.php, -// or marked by the daemon first) is left untouched. +// 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 = fopen(task_dir()."/.$type.lock", 'c'); - if ($lock) flock($lock, LOCK_EX); + $lock = task_type_lock($type); + if (!$lock) return false; $task = task_read($id); // re-read under lock $changed = false; - if ($task && $task['status']==='running') { - $task['status'] = ((int)$rc !== 0 || task_log_has_error($id)) ? 'error' : 'done'; + 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(); - task_write($task); - $changed = true; + $changed = (bool)task_write($task); + if ($changed) task_advance_locked($type); } - if ($lock) { flock($lock, LOCK_UN); fclose($lock); } - if ($changed) { task_advance($type); task_publish(); } + task_type_unlock($lock); + if ($changed) task_publish(); + return $changed; } // (re)start the scheduling daemon if it isn't already running @@ -276,18 +426,18 @@ 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; + 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 = fopen(task_dir()."/.$type.lock", 'c'); - if ($lock) flock($lock, LOCK_EX); + $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'])) { - if ($lock) { flock($lock, LOCK_UN); fclose($lock); } + if ($t['type']===$type && $t['cmd']===$cmd && in_array($t['status'],['queued','running','aborting'], true)) { + task_type_unlock($lock); return $t; } } @@ -302,17 +452,20 @@ function task_create($type,$cmd,$title,$plg,$func,$start,$button) { 'start' => (int)$start, 'button' => (int)$button, 'pid' => '', + 'pid_start'=> '', + 'pgrp' => 0, + 'session' => 0, 'status' => 'queued', 'created' => time(), 'started' => 0, 'finished' => 0, ]; - task_write($task); - if (!task_running_type($type)) task_launch($task); - if ($lock) { flock($lock, LOCK_UN); fclose($lock); } + 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']) ?: $task; + return task_read($task['id']); } // remove finished tasks older than the TTL (called by the daemon on startup) diff --git a/emhttp/plugins/dynamix/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks index 2f2236885e..f9d6556f61 100755 --- a/emhttp/plugins/dynamix/nchan/tasks +++ b/emhttp/plugins/dynamix/nchan/tasks @@ -18,7 +18,8 @@ * 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 done/error, auto-starts the next queued task of that type, + * 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. @@ -27,12 +28,6 @@ $docroot = '/usr/local/emhttp'; require_once "$docroot/plugins/dynamix/include/TaskQueue.php"; -// 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 ctype_digit((string)$pid) && file_exists("/proc/$pid"); -} - // 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 @@ -43,14 +38,38 @@ while (true) { $changed = false; $freed = []; - foreach (task_list() as $t) { - if ($t['status']==='running' && !task_pid_alive($t['pid'])) { - $t['status'] = task_log_has_error($t['id']) ? 'error' : 'done'; + 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(); - task_write($t); - $freed[$t['type']] = true; - $changed = true; + 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 @@ -72,7 +91,7 @@ while (true) { // exit when there is no more work; the daemon is restarted on demand $active = false; foreach (task_list() as $t) { - if ($t['status']==='running' || $t['status']==='queued') { $active = true; break; } + if (in_array($t['status'], ['running','aborting','queued'], true)) { $active = true; break; } } if (!$active) break; diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 12674aa3c7..85954b4b9e 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2144,7 +2144,7 @@ label.checkbox input:disabled ~ .checkmark { border-color: var(--brand-orange); } /* Right-edge grip to widen the task sheet; the width it sets is remembered per - browser (ensureTaskModalResizer in BodyInlineJS). Only shown on the .nchan + 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 { @@ -3231,4 +3231,4 @@ div#title.ud { label.checkbox input:checked ~ .checkmark { background-color: var(--brand-orange); } -} \ No newline at end of file +} From 41dbf9a869f785b3ee84c7ae9c048b8c8938de99 Mon Sep 17 00:00:00 2001 From: Eli Bosley <11823237+elibosley@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:49:33 -0400 Subject: [PATCH 23/23] fix(task-tray): serialize ordinary dialogs after minimize --- .../DefaultPageLayout/BodyInlineJS.php | 76 +++++++++++++++---- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 6cea5d3daf..a6dfb2bfd0 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -108,6 +108,16 @@ function wlanSettings() { const taskCallbackOwned = {}; var foregroundTaskId = null; +// SweetAlert reuses one global .sweet-alert node. When a task sheet is being +// minimized, do not let a second dialog reuse that node until SweetAlert's +// asynchronous close handoff has completed. Otherwise an ordinary warning +// (notably Abort) can inherit the task-sheet class and be hidden by the stale +// close callback from the previous dialog. +var taskModalClosing = false; +var taskModalCloseToken = 0; +var taskModalCloseTimer = null; +var taskModalCloseQueue = []; + function taskById(id) { for (var i=0;i').text(s==null?'':String(s)).html(); } @@ -558,21 +568,48 @@ function trayRender() { // 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:"",html:true,animation:'none',type:'warning',showCancelButton:true,confirmButtonText:"",cancelButtonText:""},function(){ - $.post(TASK_ENDPOINT,{action:'abort',id:id}); - }); + var show = function() { + // Defensive cleanup keeps this ordinary warning independent of any task + // chrome left on SweetAlert's shared singleton node. + clearNchanChrome($('.sweet-alert')); + swal({title:"",text:"",html:true,animation:'none',type:'warning',showCancelButton:true,confirmButtonText:"",cancelButtonText:""},function(){ + $.post(TASK_ENDPOINT,{action:'abort',id:id}); + }); + }; + if (taskModalClosing) taskModalCloseQueue.push(show); + else show(); } const scrollDuration = 500;