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.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index d5ade9a962..f98e82ed8b 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..1ea0258b82 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/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" ?> 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..a6dfb2bfd0 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -90,45 +90,115 @@ 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 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. +// 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 = []; +const taskPrev = {}; +const taskCallbackFired = {}; +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(); } + +// 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) {} } -var nchan_docker = new NchanSubscriber('/sub/docker',{subscriber:'websocket', reconnectTimeout:5000}); -nchan_docker.on('message', function(data) { - if (!data || openDone(data)) return; +// Live output for the foreground modal comes from the task's OWN channel +// (/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 +// 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; +var taskReplayReady = false, taskPendingMessages = [], taskReplayRequest = 0; +function startTaskChannel(id, onConnect) { + 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); }); + if (onConnect) taskSub.once('connect', onConnect); + nchanStart(taskSub); +} +function stopTaskChannel() { + if (taskSub) { nchanStop(taskSub); taskSub = null; taskSubId = null; } + taskReplayRequest++; + taskReplayReady = false; + taskPendingMessages = []; +} + +// 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 = []; +} + +// 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,63 +213,428 @@ 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]+'
'; +// live per-task channel messages render only into the foregrounded task's modal +function routeTaskMessage(id, raw) { + if (foregroundTaskId !== id || !raw) return; + if (!taskReplayReady) { taskPendingMessages.push(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 (!data) return; + if (data=='_DONE_') { openDone(data); return; } + if (data=='_ERROR_') { openError(data); return; } + var t = taskById(id); + 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'+data[1]+': '+data[2]+'.
'; + } +} + +// 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 applyNchanSheetWidth() { + var w = parseInt(localStorage.getItem(NCHAN_SHEET_WIDTH_KEY), 10); + if (!w) return; // no preference -> CSS default (60rem) + var b = nchanSheetWidthBounds(); + w = Math.max(b.min, Math.min(w, b.max)); + document.documentElement.style.setProperty('--nchan-sheet-width', w + 'px'); +} +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'); + 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 = 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('--nchan-sheet-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(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: 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; + foregroundTaskId = id; + 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 + // 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' ? " " + : 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 + if (foregroundTaskId===id) foregroundTaskId=null; + stopTaskChannel(); + clearProgressDots(); + var fresh = taskById(id); + 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').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+"
"); + // 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 || task.status=='aborting' ? "" : ""; + decorateNchanSheet({ close:'minimize', tip: closeTip }); + $('pre#swaltext').html(''); + taskReplayReady = false; + taskPendingMessages = []; + var replayStarted = false; + var connectFallback = setTimeout(function(){ + if (foregroundTaskId!==id || (taskSub && taskSub.connected)) return; + replayStarted = true; + loadTaskReplay(id, task, false); + }, 2000); + startTaskChannel(id, function(){ + clearTimeout(connectFallback); + // If the bounded fallback already started, replace its snapshot after the + // subscriber is confirmed ready. Messages arriving from this point are + // buffered and reconciled against the fresh byte cutoff. + taskReplayReady = false; + taskPendingMessages = []; + loadTaskReplay(id, task, replayStarted); + }); +} + +// react to the shared task list pushed on /sub/tasks +function onTaskListUpdate() { + for (var i=0;i re-subscribes -> sees the same done task as + // "new" -> reloads again, forever (e.g. an OS-update task stuck as + // done with func:'refresh' == infinite reload / flashing UI). The + // finished task still shows in the tray for the user to dismiss. + fireTaskCallback(t); + } + } else if ((t.status=='running' || t.status=='aborting') && foregroundTaskId==t.id) { + var activeText = t.status=='aborting' ? "" : ""; + $('#pluginProgressTitle').attr('class','nchan-state nchan-running').html(" "+activeText); + startTaskChannel(t.id); } + } + taskPrev[t.id] = t.status; + } + for (var id in taskPrev) if (!taskById(id)) delete taskPrev[id]; + trayRender(); + // let the current page react to task changes (e.g. Plugins page disables its + // update buttons while a plugin task is running). No-op where undefined. + if (typeof window.onTaskListChanged === 'function') { try { window.onTaskListChanged(); } catch(e) {} } +} + +var taskChannel = new NchanSubscriber('/sub/tasks',{subscriber:'websocket', reconnectTimeout:5000}); +taskChannel.on('message', function(msg){ + try { taskList = JSON.parse(msg) || []; } catch(e) { taskList = []; } + onTaskListUpdate(); +}); + +// Tray expand/collapse state (mobile only). On desktop the tray is always a +// full vertical stack; on mobile it collapses to a single tappable card with +// the rest peeking behind it (the iOS/Android grouped-notification pattern). +var trayExpanded = false; +function isMobileTray() { + return !!(window.matchMedia && window.matchMedia('(max-width: 767px)').matches); +} +function expandTray() { trayExpanded = true; trayRender(); } +function collapseTray() { trayExpanded = false; trayRender(); } + +// render the task tray +function trayRender() { + var $tray = $('#opTray'); + if (!$tray.length) return; + if (!taskList.length) { $tray.hide().empty(); trayExpanded = false; return; } + var rows = '', finished = 0, count = taskList.length; + // newest first: the most recent task sits at the top of the stack and is the + // single card shown when the mobile tray is collapsed. + for (var i=taskList.length-1;i>=0;i--) { + var t = taskList[i], icon, actions='', safeId = escapeTaskHtml(t.id); + var top = (i==taskList.length-1) ? ' op-top' : ''; + if (t.status=='done' || t.status=='error') finished++; + var show = "\">"; + if (t.status=='running') { + icon = ""; + actions = show + "\">"; + } else if (t.status=='aborting') { + icon = "\">"; + actions = show; + } else if (t.status=='queued') { + icon = ""; + actions = "\">"; + } else if (t.status=='done') { + icon = ""; + actions = show + "\">"; } else { - var rows_content = rows.getElementsByClassName('content'); - if (!rows_content.length || rows_content[rows_content.length-1].textContent != data[2]) { - rows.innerHTML += ''+data[2]+'.'; - } + icon = ""; + actions = show + "\">"; } - break; - case 'show_Wait': - progress_span[data[1]] = document.getElementById('wait-'+data[1]); - progress_dots[data[1]] = setInterval(function(){if (((progress_span[data[1]].innerHTML += '.').match(/\./g)||[]).length > 9) progress_span[data[1]].innerHTML = progress_span[data[1]].innerHTML.replace(/\.+$/,'');},500); - break; - case 'stop_Wait': - clearInterval(progress_dots[data[1]]); - progress_span[data[1]].innerHTML = ''; - break; - default: - box.html(box.html()+data[0]); - break; + rows += "
"+icon+""+escapeTaskHtml(t.title)+""+actions+"
"; } - box.scrollTop(box[0].scrollHeight); + // Header (only when more than one task): carries the count, a bulk "Clear + // finished" action when there is more than one finished task, and — on the + // mobile expanded stack — a chevron to collapse back to the single card. + var head = ''; + if (count > 1) { + head = "
"; + head += ""+count+" "; + head += ""; + if (finished > 1) { + head += "\"> "; + } + head += "\">"; + head += "
"; + } + // Collapsed badge: a count pill on the top card so a stacked group reads as + // "N operations" before it's expanded. + var badge = (count > 1) ? ""+count+"" : ""; + // State classes drive the CSS: collapsed shows just the top card with the + // others peeking; expanded shows the full vertical list with the header. + var mobile = isMobileTray(); + var collapsed = mobile && !trayExpanded && count > 1; + $tray.removeClass('op-collapsed op-expanded op-multi'); + if (count > 1) $tray.addClass('op-multi'); + $tray.addClass(collapsed ? 'op-collapsed' : 'op-expanded'); + $tray.html(head + rows + badge).show(); +} + +// When the mobile tray is collapsed into a single stacked card, a tap anywhere +// on it (other than a row action) expands the full list. Delegated so it +// survives trayRender() re-renders. +$(document).on('click', '#opTray.op-collapsed', function(e) { + if ($(e.target).closest('.op-act').length) return; + expandTray(); }); +// Re-evaluate collapsed/expanded layout when crossing the mobile breakpoint so +// the tray doesn't get stuck in a mobile-only collapsed state on resize. +if (window.matchMedia) { + var trayMql = window.matchMedia('(max-width: 767px)'); + var onTrayBreakpoint = function(){ if (!isMobileTray()) trayExpanded = false; trayRender(); }; + if (trayMql.addEventListener) trayMql.addEventListener('change', onTrayBreakpoint); + else if (trayMql.addListener) trayMql.addListener(onTrayBreakpoint); +} + +// minimize the foreground modal: drop the live view but leave the task running +// in the backend (the tray keeps tracking it). Backgrounding, not aborting. +// Fade the modal out with its .nchan styling intact, then strip the class once +// it's gone. Removing .nchan while the modal is still visible snaps it back to +// the default swal look for a frame (the "flash"). pointer-events:none keeps the +// fading (now-invisible but still laid-out) modal from eating clicks; the guard +// avoids stripping .nchan off a modal that was reopened in the meantime. +function clearNchanChrome($sa) { + $sa = $sa || $('.sweet-alert'); + $sa.removeClass('nchan').css('pointer-events',''); + $sa.children('.nchan-state,.nchan-close,.nchan-resize').remove(); +} + +function finishTaskModalClose(token) { + if (!taskModalClosing || token !== taskModalCloseToken) return; + if (taskModalCloseTimer) clearTimeout(taskModalCloseTimer); + taskModalCloseTimer = null; + taskModalClosing = false; + clearNchanChrome($('.sweet-alert')); + var queued = taskModalCloseQueue; + taskModalCloseQueue = []; + for (var i=0;i",text:"",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; $(window).scroll(function() { @@ -259,7 +694,8 @@ function wlanSettings() { $('html, body').scrollTop(top); } $.removeCookie('top'); - if ($.cookie('addAlert') != null) bannerAlert(addAlert.text,addAlert.cmd,addAlert.plg,addAlert.func); + // subscribe to the shared task list; the tray renders from server state + taskChannel.start(); showNotice(" "); diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index f8b697a55c..5ee628f1c1 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -28,12 +28,6 @@ // tty window var tty_window = null; -const addAlert = {}; -addAlert.text = $.cookie('addAlert-text'); -addAlert.cmd = $.cookie('addAlert-cmd'); -addAlert.plg = $.cookie('addAlert-plg'); -addAlert.func = $.cookie('addAlert-func'); - // current csrf_token var csrf_token = ""; @@ -170,126 +164,61 @@ function openTerminal(tag,name,more) { $.get('/webGui/include/OpenTerminal.php',{tag:tag,name:name,more:more},function(){setTimeout(function(){tty_window.location=socket; tty_window.focus();},200);}); } -function bannerAlert(text,cmd,plg,func,start) { - $.post('/webGui/include/StartCommand.php',{cmd:cmd,pid:1},function(pid) { - if (pid == 0) { - if ($(".upgrade_notice").hasClass('done') || timers.bannerAlert == null) { - forcedBanner = false; - if ($.cookie('addAlert') != null) { - removeBannerWarning($.cookie('addAlert')); - $.removeCookie('addAlert'); - } - $(".upgrade_notice").removeClass('alert done'); - timers.callback = null; - if (plg != null) { - if ($.cookie('addAlert-page') == null || $.cookie('addAlert-page') == '') { - 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; +// Retired: backgrounded operations are now tracked by the shared task tray +// (see the /sub/tasks subscriber and trayRender in BodyInlineJS). Kept as a +// no-op stub in case any external plugin still references it. +function bannerAlert() {} + +// openPlugin/openDocker/openVMAction keep their original signatures for all +// external callers, but now enqueue a backend-tracked task (TaskQueue.php) and +// bring it to the foreground. The backend serializes one running op per type +// and the task tray lets any client re-open or background it. +// start = 0 : run command only when not already running (default) +// start = 1 : run command unconditionally +// button : retained in the signature/record for caller compatibility; the +// shared task sheet now derives its controls from task status +function openPlugin(cmd,title,plg,func,start=0,button=0) { createTask('plugins', cmd,title,plg,func,start,button); } +function openDocker(cmd,title,plg,func,start=0,button=0) { createTask('docker', cmd,title,plg,func,start,button); } +function openVMAction(cmd,title,plg,func,start=0,button=0) { createTask('vmaction',cmd,title,plg,func,start,button); } + +function createTask(type,cmd,title,plg,func,start,button) { + $.post('/plugins/dynamix/include/TaskCommand.php',{ + action:'create', type:type, + cmd:encodeURIComponent(cmd), title:encodeURIComponent(title), + plg:plg||'', func:func||'', start:start||0, button:button||0 + },function(res) { + $('div.spinner.fixed').hide(); + if (!res || !res.id) return; + // Completion callbacks belong to tabs that initiated/joined the operation, + // not every tab that happens to observe the shared task-list transition. + taskCallbackOwned[res.id] = true; + // The authoritative task list is broadcast asynchronously on /sub/tasks and + // can land *after* this AJAX response. Without the entry in taskList, + // foregroundTask() can't find the task and bails, so the modal never opens + // even though the command runs (and logs) in the background. Seed an + // optimistic entry from what we already know; onTaskListUpdate() reconciles + // it when the real broadcast arrives. + if (typeof taskById==='function' && !taskById(res.id)) { + taskList.push({id:res.id,type:type,title:title,cmd:cmd,plg:plg||'',func:func||'', + start:start||0,button:button||0,pid:'',status:res.status||'running', + created:0,started:0,finished:0}); + taskPrev[res.id] = res.status||'running'; + if (typeof trayRender==='function') trayRender(); } - swal({title:title + ' - ',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); + foregroundTask(res.id); + },'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 +// 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}); }); } @@ -305,6 +234,7 @@ function openChanges(cmd,title,nchan,button=0) { if ($('#submit_button').length > 0) $('#submit_button').remove(); }); $('.sweet-alert').addClass('nchan'); + if (typeof decorateNchanSheet === 'function') decorateNchanSheet(); // shared close (x) + resize grip $('pre#swalbody').html(data); $('button.confirm').text("").prop('disabled',false).show(); }); @@ -317,6 +247,7 @@ function openAlert(cmd,title,func) { if (proceed) setTimeout(func+'()'); }); $('.sweet-alert').addClass('nchan'); + if (typeof decorateNchanSheet === 'function') decorateNchanSheet(); // shared close (x) + resize grip $('pre#swalbody').html(data); }); } @@ -324,14 +255,18 @@ function openAlert(cmd,title,func) { function openDone(data) { if (data == '_DONE_') { $('div.spinner.fixed').hide(); - $('button.confirm').text("").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; @@ -340,8 +275,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/include/DefaultPageLayout/MiscElementsBottom.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/MiscElementsBottom.php index 099e87f506..9d023f98ea 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/MiscElementsBottom.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/MiscElementsBottom.php @@ -14,6 +14,8 @@ + + diff --git a/emhttp/plugins/dynamix/include/TaskCapture.php b/emhttp/plugins/dynamix/include/TaskCapture.php new file mode 100644 index 0000000000..a047ed4eb0 --- /dev/null +++ b/emhttp/plugins/dynamix/include/TaskCapture.php @@ -0,0 +1,37 @@ + + diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php new file mode 100644 index 0000000000..67c0799834 --- /dev/null +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -0,0 +1,118 @@ + +$task['id'],'status'=>$task['status']] : ['error'=>'invalid'])); + +case 'abort': + $task = task_read($id); + if ($task) { + $lock = task_type_lock($task['type']); + if (!$lock) { http_response_code(503); die(); } + $task = task_read($id); + if ($task && $task['status']==='running') { + // Keep the type slot occupied until task_complete or the daemon confirms + // that the owned process group has exited. Advancing immediately after an + // asynchronous TERM can overlap two destructive operations and attribute + // the old operation's trailing output to the new task. + $task['status'] = 'aborting'; + $task['abort_requested'] = time(); + if (!task_write($task)) { + task_type_unlock($lock); + http_response_code(500); + die(); + } + task_signal_group($task, 'TERM'); + foreach (glob('/tmp/plugins/pluginPending/*') ?: [] as $file) @unlink($file); + } elseif ($task && $task['status']!=='aborting') { + // A queued task can be removed while holding the same type lock used by + // advancement, so cancellation cannot race with task_launch(). + task_delete($id); + } + task_type_unlock($lock); + if ($task && $task['status']==='aborting') task_daemon_start(); + task_publish(); + } + die(); + +case 'dismiss': + $task = task_read($id); + if ($task && in_array($task['status'],['done','error'])) { + task_delete($id); + task_publish(); + } + die(); + +case 'clear': + // remove every finished (done/error) task at once + task_clear_finished(); + task_publish(); + die(); + +case 'log': + // Output captured so far, for foreground replay. X-Task-Log-Size is the + // exact byte length served: live task-channel messages carry their log + // byte offset (see task_capture() in TaskQueue.php) and the client drops any + // live message whose offset falls below this, so the replay/live handoff + // never duplicates or loses a record. Read under the shared lock + // (task_capture() appends under the exclusive one) so the length always + // lands on a record boundary. + header('Content-Type: text/plain'); + $data = ''; + if (task_valid_id($id) && is_file(task_log($id))) { + $fh = @fopen(task_log($id), 'rb'); + if ($fh) { + if (!flock($fh, LOCK_SH)) { + fclose($fh); + my_logger("Task log replay failed to lock $id"); + http_response_code(503); + die(); + } + $data = stream_get_contents($fh) ?: ''; + flock($fh, LOCK_UN); + fclose($fh); + } + } + header('X-Task-Log-Size: '.strlen($data)); + die($data); + +case 'list': + header('Content-Type: application/json'); + die(json_encode(task_list())); +} +die(); +?> diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php new file mode 100644 index 0000000000..b96a47a494 --- /dev/null +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -0,0 +1,485 @@ + +.json per task plus a per-task .log + * capturing the operation's nchan output. Nginx sends every shared task-type + * publication through TaskCapture.php, including legacy scripts that POST + * directly to /pub/. The full task list is broadcast to all clients on + * the `tasks` nchan channel whenever it changes. + * + * Scheduling rule: at most one queue-owned active task per type at any time. + * Additional same-type operations are queued and auto-started by the `tasks` + * daemon when the active one finishes. Unrelated legacy processes can still + * publish on the shared type channels and are outside this queue invariant. + */ + +$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp'); +require_once "$docroot/webGui/include/Helpers.php"; +require_once "$docroot/webGui/include/Wrappers.php"; +require_once "$docroot/webGui/include/publish.php"; +require_once "$docroot/webGui/include/Secure.php"; + +define('TASK_DIR', '/var/local/emhttp/tasks'); +define('TASK_DAEMON', 'plugins/dynamix/nchan/tasks'); +define('TASK_DONE_TTL', 86400); // prune done/error tasks after 1 day +define('TASK_ABORT_GRACE', 5); // seconds before an abort escalates TERM -> KILL +define('TASK_TYPES', ['plugins','docker','vmaction']); + +// task ids are produced by uniqid() => lowercase hex; validate anything used in a path +function task_valid_id($id) { + return is_string($id) && preg_match('/^[a-f0-9]+$/', $id); +} + +function task_dir() { + if (!is_dir(TASK_DIR)) @mkdir(TASK_DIR, 0770, true); + return TASK_DIR; +} + +function task_path($id) { return TASK_DIR."/$id.json"; } +function task_log($id) { return TASK_DIR."/$id.log"; } + +function task_read($id) { + if (!task_valid_id($id)) return null; + $file = task_path($id); + if (!is_file($file)) return null; + $data = json_decode(@file_get_contents($file), true); + return is_array($data) ? $data : null; +} + +function task_write($task) { + task_dir(); + return file_put_contents_atomic(task_path($task['id']), json_encode($task)); +} + +function task_type_lock($type) { + if (!in_array($type, TASK_TYPES, true)) return false; + $lock = @fopen(task_dir()."/.$type.lock", 'c'); + if (!$lock) return false; + if (!flock($lock, LOCK_EX)) { fclose($lock); return false; } + return $lock; +} + +function task_type_unlock($lock) { + flock($lock, LOCK_UN); + fclose($lock); +} + +// Read the Linux process identity fields needed to distinguish a task's +// session leader from a later process that reused the same numeric PID. +function task_proc_stat($pid) { + if (!ctype_digit((string)$pid) || (int)$pid <= 1) return null; + $stat = @file_get_contents("/proc/$pid/stat"); + if (!is_string($stat)) return null; + $end = strrpos($stat, ')'); + if ($end === false) return null; + $fields = preg_split('/\s+/', trim(substr($stat, $end + 1))); + if (count($fields) < 20) return null; + return [ + 'pid' => (int)$pid, + 'state' => (string)$fields[0], // proc field 3 + 'pgrp' => (int)$fields[2], // proc field 5 + 'session' => (int)$fields[3], // proc field 6 + 'starttime' => (string)$fields[19], // proc field 22 + ]; +} + +function task_process_identity($handshake) { + for ($i = 0; $i < 200; $i++) { + $claimed = trim((string)@file_get_contents($handshake)); + $stat = task_proc_stat($claimed); + if ($stat && $stat['state']==='T' && $stat['pgrp']===(int)$claimed && $stat['session']===(int)$claimed) return $stat; + usleep(10000); + } + return null; +} + +function task_process_group_alive($task) { + $pid = (int)($task['pid'] ?? 0); + $pgrp = (int)($task['pgrp'] ?? 0); + $session = (int)($task['session'] ?? 0); + $starttime = (string)($task['pid_start'] ?? ''); + if ($pid <= 1 || $pgrp !== $pid || $session !== $pid || $starttime === '') return false; + + // If the leader PID exists but its birth time changed, the id was reused and + // must never be treated as (or signalled as) this task's process group. + $leader = task_proc_stat($pid); + if ($leader && $leader['starttime'] !== $starttime) return false; + if ($leader && $leader['state']!=='Z' && $leader['pgrp']===$pgrp && $leader['session']===$session) return true; + // The leader can exit before descendants finish. In that case scan for a + // surviving member of the original session/process group. + foreach (glob('/proc/[0-9]*/stat', GLOB_NOSORT) ?: [] as $file) { + $member = task_proc_stat(basename(dirname($file))); + if ($member && $member['state']!=='Z' && $member['pgrp']===$pgrp && $member['session']===$session) return true; + } + return false; +} + +function task_signal_group($task, $signal) { + if (!task_process_group_alive($task)) return false; + $pgrp = (int)$task['pgrp']; + exec('kill -'.$signal.' -'.$pgrp.' 2>/dev/null', $out, $rc); + return $rc === 0; +} + +function task_wait_group_exit($task, $attempts = 100) { + for ($i = 0; $i < $attempts; $i++) { + if (!task_process_group_alive($task)) return true; + usleep(10000); + } + return !task_process_group_alive($task); +} + +// Fail closed after an owned launcher exists but cannot be safely started. +// Never release its type slot until the group is confirmed gone; if it survives +// KILL, persist an aborting sentinel for the daemon to keep monitoring. +function task_fail_launch(&$task, $handshake) { + task_signal_group($task, 'KILL'); + if (task_wait_group_exit($task, 200)) { + $task['status'] = 'error'; + $task['finished'] = time(); + if (!task_write($task)) @unlink(task_path($task['id'])); + @unlink($handshake); + return false; + } + + $task['status'] = 'aborting'; + $task['abort_requested'] = 0; // daemon escalates immediately + // A prior state write may have failed. Keep the caller's type lock and retry + // until either the sentinel is durable or the stopped group is confirmed gone. + while (!task_write($task)) { + task_signal_group($task, 'KILL'); + if (task_wait_group_exit($task, 100)) { + @unlink($handshake); + @unlink(task_path($task['id'])); + return false; + } + } + return false; +} + +function task_delete($id) { + if (!task_valid_id($id)) return; + delete_file(task_path($id), task_log($id)); + task_channel_delete($id); +} + +// Drop the task's mirrored nchan channel (see task_capture()) along with the +// task. +// The generic /pub/ location keeps messages forever (nchan_message_timeout 0), +// so without this every finished task would leave its retained buffer parked in +// nchan shared memory for the life of the nginx process. +function task_channel_delete($id) { + // buffer_length is required by the /pub/ location config for every method + // (nchan_message_buffer_length $arg_buffer_length); without it nginx errors + // out before nchan sees the DELETE + $com = curl_init("http://localhost/pub/task-$id?buffer_length=1"); + curl_setopt_array($com, [ + CURLOPT_UNIX_SOCKET_PATH => '/var/run/nginx.socket', + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => 1, + ]); + curl_exec($com); + curl_close($com); +} + +// all tasks, oldest first (FIFO by creation time, id breaks ties) +function task_list() { + $tasks = []; + foreach (glob(TASK_DIR.'/*.json') ?: [] as $file) { + $data = json_decode(@file_get_contents($file), true); + if (is_array($data) && isset($data['id'])) $tasks[] = $data; + } + usort($tasks, function($a,$b) { + return ($a['created'] <=> $b['created']) ?: strcmp($a['id'],$b['id']); + }); + return $tasks; +} + +// broadcast the full list to every connected client +function task_publish() { + publish('tasks', json_encode(task_list())); +} + +// the single running task of a type, or null +function task_running_type($type) { + foreach (task_list() as $t) + if ($t['type']===$type && in_array($t['status'], ['running','aborting'], true)) return $t; + return null; +} + +// Persist and mirror one message published on a shared task-type channel. +// Capture happens from Nginx's publisher hook instead of publish.php because +// third-party plugin scripts commonly POST straight to /pub/plugins. The task +// queue guarantees at most one queue-owned active task per type. A publication +// from an unrelated process of the same type remains a known attribution limit. +// +// Messages are RS-delimited in the log. The task channel carries the record's +// byte offset as "\x1f", allowing the foreground client to +// dedupe precisely against a simultaneous log replay. The append is exclusive +// and TaskCommand.php reads under a shared lock, so offsets always land on +// record boundaries. +function task_capture($type, $message) { + if (!in_array($type, TASK_TYPES, true)) return false; + $task = task_running_type($type); + if (!$task || !task_valid_id($task['id'])) return false; + + $fh = @fopen(task_log($task['id']), 'c'); + if (!$fh) return false; + if (!flock($fh, LOCK_EX)) { + fclose($fh); + my_logger("Task capture failed to lock log for {$task['id']}"); + return false; + } + fseek($fh, 0, SEEK_END); + $offset = ftell($fh); + $record = $message."\x1e"; + $written = $offset !== false ? fwrite($fh, $record) : false; + $complete = $written === strlen($record); + if (!$complete && $offset !== false) ftruncate($fh, $offset); + fflush($fh); + if (!$complete) { + flock($fh, LOCK_UN); + fclose($fh); + my_logger("Task capture failed to append log for {$task['id']}"); + return false; + } + + // A small retained buffer lets a foreground subscriber joining mid-stream + // catch up; byte offsets make any redelivery harmless. Keep the log lock + // through the mirror publish so concurrent writers preserve offset order. + publish("task-{$task['id']}", $offset."\x1f".$message, 10); + flock($fh, LOCK_UN); + fclose($fh); + return true; +} + +// resolve a command to an absolute script path the same way StartCommand.php does +function task_resolve($cmd) { + global $docroot; + [$command,$args] = array_pad(explode(' ', unscript($cmd), 2), 2, ''); + $name = ''; + $path = ''; + foreach (glob("$docroot/plugins/*/scripts", GLOB_NOSORT) as $path) { + if ($name = realpath("$path/$command")) break; + } + if (!$command || !$name || strncmp($name,$path,strlen($path))!==0) return null; + return [$name, $args]; +} + +// launch a task in the background; Nginx's publisher hook captures its output +function task_launch(&$task) { + global $docroot; + // guard: never run two of the same type at once + if (task_running_type($task['type'])) return false; + $resolved = task_resolve($task['cmd']); + if (!$resolved) { + $task['status'] = 'error'; + $task['finished'] = time(); + if (!task_write($task)) @unlink(task_path($task['id'])); + return false; + } + [$name,$args] = $resolved; + // plugin scripts publish to nchan only when their last argument is 'nchan' + $suffix = $task['type']==='plugins' ? ' nchan' : ''; + // Keep the task id available to task-aware scripts and diagnostics. Output + // capture itself no longer depends on this variable: TaskCapture.php handles + // every publication on the shared type channel, including legacy scripts. + $env = 'NCHAN_TASK='.escapeshellarg($task['id']).' '; + // The command records its own terminal state on exit: capture its exit code + // and hand it to task_complete, which marks the task done/error, advances the + // queue and broadcasts. This makes completion authoritative at the source + // instead of relying on the scheduler daemon to observe the PID disappear + // (which it can miss on PID reuse or a daemon-restart race). NCHAN_TASK is + // cleared before the completion helper because the operation itself is over. + // The daemon stays a fallback for the case where the process is hard-killed + // before the stamp can run. + $complete = "$docroot/plugins/dynamix/include/task_complete"; + $stamp = '; rc=$?; NCHAN_TASK= '.escapeshellarg($complete).' '.escapeshellarg($task['id']).' "$rc"'; + // escapeshellarg the whole bash -c payload so a single quote (or other shell + // metacharacter) in the resolved args cannot break out of the outer shell; + // bash still word-splits the args internally, preserving multi-arg commands. + // The wrapper writes its own PID and stops before the operation begins. PHP + // validates + persists that process identity, then resumes the group. This + // handshake means no privileged payload can run untracked and cleanup never + // has to signal an unverified numeric PID. + $handshake = task_dir().'/.launch-'.$task['id']; + @unlink($handshake); + $gate = 'printf "%s\\n" "$$" > '.escapeshellarg($handshake).' || exit 125; kill -STOP $$; rm -f '.escapeshellarg($handshake).'; '; + $payload = $gate.'sleep .3 && '.$name.' '.$args.$suffix.$stamp; + // setsid runs the operation in its own session + process group (pid == pgid), + // so Abort (TaskCommand.php) can signal the whole tree via a negative-pid group + // kill and actually stop the underlying command and every child it spawned. + // With a plain nohup the wrapper shell stays in php-fpm's process group: killing + // just its pid orphaned the real worker (e.g. a docker update), which then ran + // to completion after the task was already marked error. + exec($env.'setsid bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 &'); + $identity = task_process_identity($handshake); + if (!$identity) { + // A matching handshake file plus a stopped process proves ownership even + // when setsid failed to establish the expected group. Kill only that owned + // launcher PID; never fall back to an unverified numeric PID or group. + $claimed = trim((string)@file_get_contents($handshake)); + $stopped = task_proc_stat($claimed); + if ($stopped && $stopped['state']==='T') exec('kill -KILL '.(int)$claimed.' 2>/dev/null'); + @unlink($handshake); + $task['status'] = 'error'; + $task['finished'] = time(); + if (!task_write($task)) @unlink(task_path($task['id'])); + return false; + } + $pid = (string)$identity['pid']; + $task['pid'] = $pid; + $task['pid_start'] = $identity['starttime']; + $task['pgrp'] = $identity['pgrp']; + $task['session'] = $identity['session']; + $task['status'] = 'running'; + $task['started'] = time(); + if (!task_write($task)) return task_fail_launch($task, $handshake); + if (!task_signal_group($task, 'CONT')) return task_fail_launch($task, $handshake); + @unlink($handshake); + return $pid; +} + +// Caller holds the per-type lock. +function task_advance_locked($type) { + if (task_running_type($type)) return false; + foreach (task_list() as $task) { + if ($task['type']===$type && $task['status']==='queued') return task_launch($task); + } + return false; +} + +// start the next queued task of a type if nothing of that type is running. +// Takes the per-type lock so the check-and-launch is atomic against task_create +// and task_complete; without it two advancers (e.g. the daemon and a task's own +// completion stamp firing at the same instant) could both pass task_running_type +// and double-launch the next queued task. Callers must NOT already hold the lock. +function task_advance($type) { + $lock = task_type_lock($type); + if (!$lock) return false; + task_advance_locked($type); + task_type_unlock($lock); + return true; +} + +// the operation reported a failure if its captured output contains the _ERROR_ +// control record. The log is RS(\x1e)-delimited and _DONE_/_ERROR_ are discrete +// records (see task_capture()), so match _ERROR_ as a whole record the same way +// the live channel does (routeMessage) — a log line that merely contains the +// text must not trip a false failure. +function task_log_has_error($id) { + $log = task_log($id); + if (!is_file($log)) return false; + $fh = @fopen($log, 'rb'); + if (!$fh) return false; + if (filesize($log) > 65536) fseek($fh, -65536, SEEK_END); + $tail = stream_get_contents($fh); + fclose($fh); + return in_array('_ERROR_', explode("\x1e", (string)$tail), true); +} + +// Record a task's terminal state from its command's own exit (invoked by the +// wrapper in task_launch via plugins/dynamix/include/task_complete). $rc is the +// command's exit code; a task is an error when it exited non-zero or published +// an _ERROR_ record. Marking and selection of the next queued task are atomic +// under the per-type lock. An already-finalized task (or one marked by the +// daemon first) is left untouched. +function task_complete($id, $rc) { + $task = task_read($id); + if (!$task) return; + $type = $task['type']; + $lock = task_type_lock($type); + if (!$lock) return false; + $task = task_read($id); // re-read under lock + $changed = false; + if ($task && in_array($task['status'], ['running','aborting'], true)) { + $task['status'] = ($task['status']==='aborting' || (int)$rc !== 0 || task_log_has_error($id)) ? 'error' : 'done'; + $task['finished'] = time(); + $changed = (bool)task_write($task); + if ($changed) task_advance_locked($type); + } + task_type_unlock($lock); + if ($changed) task_publish(); + return $changed; +} + +// (re)start the scheduling daemon if it isn't already running +function task_daemon_start() { + global $docroot; + $script = "$docroot/".TASK_DAEMON; + exec('pgrep --ns $$ -f '.escapeshellarg($script), $out, $ret); + if ($ret !== 0) exec(escapeshellarg($script).' >/dev/null 2>&1 &'); +} + +// create (and possibly immediately start) a task; returns the task record +function task_create($type,$cmd,$title,$plg,$func,$start,$button) { + if (!in_array($type, TASK_TYPES, true)) return null; + // Serialize the dedupe -> create -> launch sequence per type. Without this, + // two concurrent requests (e.g. a double click) could both pass the dedupe + // and task_running_type() checks and each call task_launch(), violating the + // "at most one running task per type" invariant the whole design relies on. + $lock = task_type_lock($type); + if (!$lock) return null; + // dedupe: unless unconditional (start==1), don't queue an identical pending/running op + if ((int)$start !== 1) { + foreach (task_list() as $t) { + if ($t['type']===$type && $t['cmd']===$cmd && in_array($t['status'],['queued','running','aborting'], true)) { + task_type_unlock($lock); + return $t; + } + } + } + $task = [ + 'id' => uniqid(), + 'type' => $type, + 'title' => $title, + 'cmd' => $cmd, + 'plg' => $plg, + 'func' => $func, + 'start' => (int)$start, + 'button' => (int)$button, + 'pid' => '', + 'pid_start'=> '', + 'pgrp' => 0, + 'session' => 0, + 'status' => 'queued', + 'created' => time(), + 'started' => 0, + 'finished' => 0, + ]; + if (!task_write($task)) { task_type_unlock($lock); return null; } + task_advance_locked($type); + task_type_unlock($lock); + task_daemon_start(); + task_publish(); + return task_read($task['id']); +} + +// remove finished tasks older than the TTL (called by the daemon on startup) +function task_prune() { + $now = time(); + foreach (task_list() as $t) { + if (in_array($t['status'],['done','error']) && ($now - ($t['finished'] ?: $t['created'])) > TASK_DONE_TTL) + task_delete($t['id']); + } +} + +// remove every finished task now (the tray's "Clear finished" action) +function task_clear_finished() { + foreach (task_list() as $t) + if (in_array($t['status'],['done','error'])) task_delete($t['id']); +} +?> diff --git a/emhttp/plugins/dynamix/include/task_complete b/emhttp/plugins/dynamix/include/task_complete new file mode 100755 index 0000000000..9c5ae39623 --- /dev/null +++ b/emhttp/plugins/dynamix/include/task_complete @@ -0,0 +1,29 @@ +#!/usr/bin/php -q + + + * + * Recording completion at the source means a task is marked done/error the + * instant its command exits, independent of the scheduler daemon observing the + * PID disappear (which it can miss on PID reuse or a daemon-restart race). + */ +$docroot = '/usr/local/emhttp'; +require_once "$docroot/plugins/dynamix/include/TaskQueue.php"; + +task_complete($argv[1] ?? '', (int)($argv[2] ?? 0)); +?> diff --git a/emhttp/plugins/dynamix/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks new file mode 100755 index 0000000000..f9d6556f61 --- /dev/null +++ b/emhttp/plugins/dynamix/nchan/tasks @@ -0,0 +1,102 @@ +#!/usr/bin/php -q + += TASK_ABORT_GRACE) + task_signal_group($t, 'KILL'); + task_type_unlock($lock); + continue; + } + if (!$alive) { + // A successful/ordinary failure exit stamps its authoritative result via + // task_complete before disappearing. Reaching this fallback means the + // wrapper was killed, crashed, or never reached its stamp, so success is + // unknown and must fail closed even when no _ERROR_ record was published. + $t['status'] = 'error'; + $t['finished'] = time(); + if (task_write($t)) { + @unlink(task_dir().'/.launch-'.$t['id']); + task_advance_locked($t['type']); + $changed = true; + } + } + task_type_unlock($lock); + } + + // also launch any queued type that has nothing running. Covers a daemon + // (re)started by the page-load nchan sweep with queued-but-not-running + // records: without this it would treat those as "active" below and sleep + // forever without ever advancing the queue. + foreach (task_list() as $t) { + if ($t['status']==='queued' && !task_running_type($t['type'])) { + $freed[$t['type']] = true; + $changed = true; + } + } + + // start the next queued op for every type that just freed up + foreach (array_keys($freed) as $type) task_advance($type); + + if ($changed) task_publish(); + + // exit when there is no more work; the daemon is restarted on demand + $active = false; + foreach (task_list() as $t) { + if (in_array($t['status'], ['running','aborting','queued'], true)) { $active = true; break; } + } + if (!$active) break; + + usleep(250000); // 250ms, same cadence as the file_manager worker +} + +removeNChanScript(); +?> diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 17f5015393..85954b4b9e 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1912,12 +1912,402 @@ label.checkbox input:disabled ~ .checkmark { font-size: 2.5rem; z-index: 999; } +/* Background operation task tray (see BodyInlineJS trayRender) */ +.op-tray { + position: fixed; + right: 1rem; + bottom: 1rem; + width: 320px; + max-width: 90vw; + max-height: 60vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: .25rem; + z-index: 1000; +} +/* Lift the tray above the fixed footer so it isn't clipped behind it. + Mirrors the media queries that make #footer position:fixed. */ +@media (min-width: 768px) { + .op-tray { bottom: calc(40px + 1rem); } +} +@media (min-height: 500px) and (orientation: landscape) { + .op-tray { bottom: calc(30px + 1rem); } +} +.op-tray .op-tray-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + padding: 0 .35rem .15rem; +} +.op-tray .op-tray-count { + font-size: 1.05rem; + color: var(--text-color); + opacity: .6; + text-transform: uppercase; + letter-spacing: .03em; +} +.op-tray .op-tray-head-acts { + display: flex; + align-items: center; + gap: .65rem; +} +.op-tray .op-tray-head .op-act { + font-size: 1.1rem; + color: var(--text-color); + opacity: .75; + cursor: pointer; + text-decoration: none; +} +.op-tray .op-tray-head .op-act:hover { opacity: 1; color: var(--brand-orange); } +/* The collapse chevron and the count badge are part of the mobile stacked-card + interaction; desktop always shows the full vertical list, so hide them there. */ +.op-tray .op-tray-head .op-collapse { display: none; } +.op-tray .op-stack-badge { display: none; } +.op-tray .op-task { + display: flex; + align-items: center; + gap: .5rem; + padding: .5rem .75rem; + font-size: 1.3rem; + color: var(--text-color); + background-color: var(--background-color); + border: 1px solid var(--border-color); + border-left: 3px solid var(--brand-orange); + border-radius: .5rem; + box-shadow: 0 1px 4px rgba(0,0,0,.25); +} +.op-tray .op-task.op-done { border-left-color: var(--green-800); } +.op-tray .op-task.op-error { border-left-color: var(--red-600); } +.op-tray .op-task.op-queued { border-left-color: var(--gray-500); } +.op-tray .op-icon { flex: 0 0 auto; } +.op-tray .op-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.op-tray .op-actions { + flex: 0 0 auto; + display: flex; + gap: .5rem; +} +.op-tray .op-act { + cursor: pointer; + color: var(--text-color); + text-decoration: none; +} +.op-tray .op-act:hover { color: var(--brand-orange); } +/* The foreground task modal: a compact, centered card (a transient progress log + shouldn't take over the screen, so it is NOT a full-height sidebar). The base + swal keeps the centering + rounded corners; here we just shrink it and lay it + out as header / scrolling log / footer. All colors are theme tokens so it + adapts to white / azure / black / gray: + surface = --dynamix-sweet-alert-icon-bg-color text = --dynamix-sweet-alert-text-color + muted = --alt-text-color card = --shade-bg-color lines = --border-color + accent = --brand-orange. Mobile goes fullscreen (below). */ +.sweet-alert.nchan { + display: flex !important; + flex-direction: column; + /* user-resizable width: drag the right-edge grip to widen. The chosen width + is stored per browser in localStorage and re-applied via --nchan-sheet-width + (see applyNchanSheetWidth / ensureNchanResizer in BodyInlineJS), shared by + every .nchan sheet. Defaults to the 60rem width, always capped to viewport. */ + width: min(var(--nchan-sheet-width, 60rem), 90vw); + height: 75vh; /* fixed (overrides swal's @media 95vh) so the log scrolls in place and the Dismiss button never moves; scales with the viewport */ + max-height: 85vh; + margin: 0; + padding: 0; + text-align: left; + overflow: hidden; + box-sizing: border-box; + box-shadow: 0 0.5rem 2rem var(--bg-opacity-30); +} +/* SweetAlert adds 40px bottom padding when no buttons show (our running state); + the sheet manages its own padding, so cancel it. Matches swal's selector plus + .nchan to outspecify it. */ +.sweet-alert.nchan[data-has-confirm-button=false][data-has-cancel-button=false] { + padding-bottom: 0; +} +/* While running there is no Dismiss button, so hide the whole footer bar + (otherwise its border-top + padding render as an empty strip). */ +.sweet-alert.nchan[data-has-confirm-button=false] .sa-button-container { + display: none; +} +.sweet-alert.nchan > h2 { + flex: 0 0 auto; + margin: 0; + padding: 1.1rem 3.6rem 0.85rem 1.2rem; + font-size: 1.6rem; + line-height: 1.35; + font-weight: 600; + text-align: left; +} +/* status state strip below the title: a subtle theme-adaptive tint + a colored + icon, with normal (theme) text. Tints are translucent so they read over the + modal surface in every theme without the dated pale-banner look. */ +.sweet-alert.nchan .nchan-state { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + padding: 0.5rem 1.2rem; + font-size: 1.1rem; + font-weight: 500; + color: var(--text-color); + border-bottom: 1px solid var(--border-color); +} +.sweet-alert.nchan .nchan-state i { font-size: 1.05em; } +.sweet-alert.nchan .nchan-state.nchan-running { background: rgba(59,130,196,.14); } +.sweet-alert.nchan .nchan-state.nchan-running i { color: #3b82c4; } +.sweet-alert.nchan .nchan-state.nchan-done { background: rgba(58,156,71,.16); } +.sweet-alert.nchan .nchan-state.nchan-done i { color: #3a9c47; } +.sweet-alert.nchan .nchan-state.nchan-error { background: rgba(226,75,74,.14); } +.sweet-alert.nchan .nchan-state.nchan-error i { color: #e24b4a; } +.sweet-alert.nchan > p { + flex: 1 1 auto; /* fill the fixed height so the footer stays pinned to the bottom */ + min-height: 0; + margin: 0; + padding: 1rem 1.2rem; + display: flex; + flex-direction: column; + text-align: left; +} +.sweet-alert.nchan #swaltext { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow: auto; + margin: 0; + padding: 0; + text-align: left; + font-size: 1.2rem; +} +/* Slim footer with a full-width Dismiss. A single centered button looked lonely + and the default container (margin-top + centered flex + a hidden loader) ate + vertical space. */ +.sweet-alert.nchan .sa-button-container { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; + margin: 0; + padding: 0.7rem 1.2rem; + border-top: 1px solid var(--border-color); +} +.sweet-alert.nchan .sa-confirm-button-container { display: flex; justify-content: flex-end; } +.sweet-alert.nchan .la-ball-fall { display: none; } +/* !important overrides SweetAlert's inline background-color:transparent / sizing */ +.sweet-alert.nchan button.confirm { + display: inline-block; + width: auto; + margin: 0; + padding: 0.5rem 1.4rem; + font-size: 1.25rem; + color: var(--white) !important; + background: var(--brand-orange) !important; + border: 0 !important; + border-radius: 0.4rem; + box-shadow: none !important; +} +.sweet-alert.nchan button.confirm:hover { background: var(--orange-800) !important; } +/* top-corner minimize (task sheets) / close (changelog, alert sheets) control — + theme-token colors so it stays visible on the modal surface in every theme. + Scoped to .nchan so a control left on the shared swal node never shows on an + ordinary dialog. */ +.sweet-alert:not(.nchan) .nchan-close { display: none; } +.sweet-alert .nchan-close { + position: absolute; + top: 0.85rem; + right: 0.9rem; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 1.3rem; + line-height: 1; + color: var(--dynamix-sweet-alert-text-color); + background: transparent; + border: 1px solid var(--border-color); + cursor: pointer; + text-decoration: none; + transition: background-color .15s, border-color .15s, color .15s; + z-index: 2; +} +.sweet-alert .nchan-close:hover { + color: var(--white); + background: var(--brand-orange); + border-color: var(--brand-orange); +} +/* Right-edge grip to widen the task sheet; the width it sets is remembered per + browser (ensureNchanResizer in BodyInlineJS). Only shown on the .nchan + task sheet, never on other reused .sweet-alert dialogs. */ +.sweet-alert .nchan-resize { display: none; } +.sweet-alert.nchan .nchan-resize { + display: block; + position: absolute; + top: 3.4rem; /* clear the minimize button in the top-right corner */ + right: 0; + bottom: 0; + width: 10px; + cursor: ew-resize; + z-index: 1; /* below .nchan-close (z-index:2) */ + touch-action: none; /* pointer events drive the drag, not scroll */ +} +.sweet-alert.nchan .nchan-resize::before { + content: ""; + position: absolute; + top: 50%; + right: 3px; + width: 3px; + height: 3.6rem; + margin-top: -1.8rem; + border-radius: 2px; + background: var(--border-color); + transition: background-color .15s; +} +.sweet-alert.nchan .nchan-resize:hover::before { background: var(--brand-orange); } +/* Mobile: fullscreen (a centered card is cramped on phones). Overrides the base + swal centering to fill the viewport. */ +@media (max-width: 767px) { + .sweet-alert.nchan { + left: 0; + top: 0; + right: 0; + bottom: 0; + transform: none; + -webkit-transform: none; + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + border: 0; + border-radius: 0; + } + .sweet-alert.nchan .nchan-close { width: 2.9rem; height: 2.9rem; font-size: 1.6rem; } + .sweet-alert.nchan .nchan-resize { display: none; } /* fullscreen: nothing to resize */ +} .back_to_top { right: 40px; } .move_to_end { right:12px; } +/* The scroll-to-top/bottom buttons share the bottom-right corner with the task + tray. When the tray has tasks (it is an earlier sibling of the buttons, see + MiscElementsBottom), move the buttons clear of it: to the bottom-left on + desktop. */ +.op-tray:not(:empty) ~ .back_to_top { right: auto; left: 40px; } +.op-tray:not(:empty) ~ .move_to_end { right: auto; left: 12px; } +/* Mobile: the tray is a grouped-notification stack (iOS/Android pattern). When + there are multiple tasks it collapses to the newest card with the rest peeking + behind it; a tap expands the full vertical list so all of them can be viewed + and cleared at once. The scroll buttons lift above it so they never overlap. */ +@media (max-width: 767px) { + .op-tray { + left: 1rem; + right: 1rem; + width: auto; + max-width: none; + gap: .5rem; + } + /* larger tap targets on touch (applies in both collapsed and expanded) */ + .op-tray .op-task { padding: .85rem 1rem; min-height: 3.4rem; font-size: 1.45rem; gap: .75rem; } + .op-tray .op-actions { gap: .25rem; } + .op-tray .op-act { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3rem; + min-height: 3rem; + font-size: 1.6rem; + } + + /* ---- Collapsed: a single tappable card with stacked edges peeking under ---- */ + .op-tray.op-collapsed { + display: block; + position: fixed; + max-height: none; + overflow: visible; + /* room under the front card for the two peeking stack edges + the badge */ + padding-bottom: .9rem; + cursor: pointer; + } + .op-tray.op-collapsed .op-tray-head { display: none; } + .op-tray.op-collapsed .op-task { display: none; } + .op-tray.op-collapsed .op-task.op-top { + display: flex; + position: relative; + z-index: 2; + margin: 0; + } + /* collapsed = whole card means "expand"; its row actions are reachable once + expanded, so suppress them here to keep the tap target unambiguous */ + .op-tray.op-collapsed .op-task.op-top .op-actions { display: none; } + /* two faux card edges fanned out below the front card to signal a stack */ + .op-tray.op-collapsed.op-multi .op-task.op-top::before, + .op-tray.op-collapsed.op-multi .op-task.op-top::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + height: 1rem; + border: 1px solid var(--border-color); + border-top: 0; + border-bottom-left-radius: .5rem; + border-bottom-right-radius: .5rem; + background-color: var(--background-color); + z-index: -1; + } + .op-tray.op-collapsed.op-multi .op-task.op-top::after { + bottom: -.45rem; + width: 92%; + opacity: .8; + } + .op-tray.op-collapsed.op-multi .op-task.op-top::before { + bottom: -.9rem; + width: 84%; + opacity: .55; + } + /* count pill on the front card */ + .op-tray.op-collapsed .op-stack-badge { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + top: -.7rem; + right: -.4rem; + z-index: 3; + min-width: 1.6rem; + height: 1.6rem; + padding: 0 .45rem; + border-radius: .8rem; + background-color: var(--brand-orange); + color: #fff; + font-size: 1.05rem; + font-weight: 700; + box-shadow: 0 1px 3px rgba(0,0,0,.35); + cursor: pointer; + } + + /* ---- Expanded: full vertical list, panned up from the bottom ---- */ + .op-tray.op-expanded { + display: flex; + flex-direction: column; + max-height: 70vh; + overflow-y: auto; + } + .op-tray.op-expanded .op-tray-head .op-collapse { display: inline-flex; } + + /* lift the scroll buttons clear of the taller mobile tray (front card + + peeking stack edges + count badge sit ~5.5rem up from the bottom) */ + .op-tray:not(:empty) ~ .back_to_top { left: auto; right: 40px; bottom: 7.5rem; } + .op-tray:not(:empty) ~ .move_to_end { left: auto; right: 12px; bottom: 7.5rem; } +} span.big.blue-text { cursor: pointer; } @@ -2841,4 +3231,4 @@ div#title.ud { label.checkbox input:checked ~ .checkmark { background-color: var(--brand-orange); } -} \ No newline at end of file +} diff --git a/etc/rc.d/rc.nginx b/etc/rc.d/rc.nginx index 5bb087071d..1197a3d57e 100755 --- a/etc/rc.d/rc.nginx +++ b/etc/rc.d/rc.nginx @@ -170,6 +170,30 @@ build_servers(){ nchan_message_buffer_length $arg_buffer_length; nchan_message_timeout 30s; } + # Capture task output at the publisher boundary. Existing plugin scripts + # (including Community Apps and plugin_rm) POST directly to /pub/plugins + # and never call WebGUI's publish(), so this hook is the only common path + # for both legacy and first-party publishers. + location ~ ^/pub/(plugins|docker|vmaction)$ { + nchan_publisher; + nchan_channel_id "$1"; + nchan_message_buffer_length $arg_buffer_length; + nchan_message_timeout 0; + nchan_publisher_upstream_request /task-capture; + } + location = /task-capture { + internal; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/local/emhttp/plugins/dynamix/include/TaskCapture.php; + fastcgi_param SCRIPT_NAME /plugins/dynamix/include/TaskCapture.php; + fastcgi_param REQUEST_URI "/task-capture/$nchan_channel_id"; + # Nchan forwards the published message as the request body. Use GET + # for the internal FastCGI hop so the global browser-facing CSRF + # check does not reject this trusted, non-browser request. + fastcgi_param REQUEST_METHOD GET; + fastcgi_param QUERY_STRING "type=$nchan_channel_id"; + fastcgi_param HTTP_X_TASK_CAPTURE 1; + } location ~ /pub/(.*)$ { nchan_publisher; nchan_channel_id "$1";