From 65014d03a1bbb6a0eac20d0912a66aaff578e299 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 16:26:43 -0400 Subject: [PATCH 01/31] 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 000000000..b24ef5b01 --- /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 eb40e02b7..5253b9741 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 8b336954f..c2091691a 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 8ab303c5b..378dd88d0 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 3ee760ebc..4cfba97ac 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 e963cbd6b..5b2cc265e 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 14114d779..dc4cbdfc5 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1939,6 +1939,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 e7b161e665f3eda43ab0ab32a4f3af045d6298e4 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 17:35:56 -0400 Subject: [PATCH 02/31] feat(task-tray): backgroundable running ops + corrected modal buttons The foreground task modal drove its single button off an inverted per-type `button` flag. For docker/vmaction (default button=0) this hid the Close button for the entire running phase AND left the confirm button disabled, which surfaced SweetAlert's `.la-ball-fall` loader (the "bouncing green dots") with no way to background the operation. Drive the modal by task status instead, uniformly for all types: - running -> a top-corner minimize control backgrounds the task (it keeps running under the daemon and stays in the tray); no disabled button, so the bouncing-dots loader never appears. The spinning title icon remains the in-progress indicator. - done/err -> a single primary "Dismiss" button that fires the reload callback and clears the task from the tray. Esc while running now backgrounds (instead of deleting) since the done-callback checks live status. No caller passes the `button` flag, so no real behavior contract changes; `task.button` is now inert (follow-up: drop it from the task record + open* signatures). Stacked on the backend task queue (#2665). OS-461 Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/BodyInlineJS.php | 31 ++++++++++++++++--- .../DefaultPageLayout/HeadInlineJS.php | 8 +++-- .../plugins/dynamix/styles/default-base.css | 14 +++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 5253b9741..e348ed53e 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -211,22 +211,32 @@ 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; + // 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'; var titleState = task.status=='done' ? "" : task.status=='error' ? "" : " "; - swal({title:escapeTaskHtml(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: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); } trayRender(); }); $('.sweet-alert').addClass('nchan'); - $('button.confirm').prop('disabled',disable); + // a top-corner minimize for the running phase: backgrounds the task (it keeps + // running in the tray) without aborting; openDone/openError swap it for Dismiss + $('.sweet-alert .nchan-close').remove(); + if (!finished) $('.sweet-alert').append("\" onclick='minimizeForegroundTask()'>"); $('pre#swaltext').html(''); $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata){ if (foregroundTaskId!==id) return; // user moved on while loading @@ -307,6 +317,17 @@ function trayRender() { $tray.html(header + rows).show(); } +// minimize the foreground modal: drop the live view but leave the task running +// in the backend (the tray keeps tracking it). Backgrounding, not aborting. +function minimizeForegroundTask() { + if (foregroundTaskId) { foregroundTaskId=null; foregroundType=null; } + stopAllTypeChannels(); + clearProgressDots(); + $('.sweet-alert').removeClass('nchan'); + if (typeof swal!=='undefined' && swal.close) swal.close(); + trayRender(); +} + 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'}); } diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index c2091691a..5e2859919 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -248,7 +248,10 @@ function openAlert(cmd,title,func) { function openDone(data) { if (data == '_DONE_') { $('div.spinner.fixed').hide(); - $('button.confirm').text("").prop('disabled',false).show(); + // task finished: the running-phase minimize control gives way to a primary + // Dismiss button (clears the task from the tray on confirm) + $('.sweet-alert .nchan-close').remove(); + $('button.confirm').text("").prop('disabled',false).show(); if (typeof ca_done_override !== 'undefined') { if (ca_done_override == true) { $("button.confirm").trigger("click"); @@ -264,7 +267,8 @@ function openDone(data) { function openError(data) { if (data == '_ERROR_') { $('div.spinner.fixed').hide(); - $('button.confirm').text("").prop('disabled',false).show(); + $('.sweet-alert .nchan-close').remove(); + $('button.confirm').text("").prop('disabled',false).show(); $('#pluginProgressTitle').text(""); return true; } diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index dc4cbdfc5..106bf6c52 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1955,6 +1955,20 @@ label.checkbox input:disabled ~ .checkmark { text-decoration: none; } .op-tray .op-act:hover { color: var(--brand-orange); } +/* top-corner minimize on the foreground task modal (backgrounds the task) */ +.sweet-alert .nchan-close { + position: absolute; + top: .6rem; + right: .9rem; + font-size: 1.3rem; + line-height: 1; + color: var(--text-color); + opacity: .6; + cursor: pointer; + text-decoration: none; + z-index: 2; +} +.sweet-alert .nchan-close:hover { opacity: 1; color: var(--brand-orange); } .back_to_top { right: 40px; } From 8c42f4b5adc587e7df4fb3c19b56bb64f60bd3e6 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 17:40:19 -0400 Subject: [PATCH 03/31] feat(task-tray): fullscreen the task modal on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreground task modal is a 90vw centered, rounded card — on phones that reads as a cramped "circular" blob. On <=767px take it fullscreen (flush, no radius), pin the title at top, let the log fill and scroll, and keep the corner minimize + bottom Dismiss reachable. Scoped to `.nchan` so ordinary confirm/warning dialogs keep their dialog look. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/styles/default-base.css | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 106bf6c52..b50da313b 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1969,6 +1969,44 @@ label.checkbox input:disabled ~ .checkmark { z-index: 2; } .sweet-alert .nchan-close:hover { opacity: 1; color: var(--brand-orange); } +/* Mobile: the foreground task modal goes fullscreen instead of a centered, + rounded card (which looks cramped/"circular" on small screens). Scoped to + .nchan so ordinary confirm/warning dialogs keep their dialog look. The title + stays pinned at the top, the log fills and scrolls, and the minimize / Dismiss + controls stay reachable at the corners/bottom. */ +@media (max-width: 767px) { + .sweet-alert.nchan { + display: flex !important; + flex-direction: column; + left: 0; + top: 0; + width: 100vw; + max-width: 100vw; + height: 100vh; + height: 100dvh; + max-height: 100vh; + max-height: 100dvh; + margin: 0; + padding: 1.25rem 1rem; + border: 0; + border-radius: 0; + transform: none !important; + -webkit-transform: none !important; + box-sizing: border-box; + } + .sweet-alert.nchan > h2 { flex: 0 0 auto; padding-right: 2.5rem; } + .sweet-alert.nchan > p { flex: 1 1 auto; min-height: 0; display: flex; margin: 0; } + .sweet-alert.nchan #swaltext { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow: auto; + margin: 0; + text-align: left; + } + .sweet-alert.nchan .sa-button-container { flex: 0 0 auto; margin-top: 1rem; } + .sweet-alert.nchan .nchan-close { top: 1.1rem; right: 1.1rem; font-size: 1.7rem; opacity: .85; } +} .back_to_top { right: 40px; } From 5614d61086f1d14d21d6366405670dbfdd60f032 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 17:57:08 -0400 Subject: [PATCH 04/31] feat(task-tray): keep the modal close control on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design review: the top-corner control is now a persistent "close this window" present in every state, rather than vanishing when the task finishes. While running it reads as minimize (fa-window-minimize, task keeps running); once finished it swaps to a plain close (fa-times, task stays in the tray). Removal stays the separate primary Dismiss action — mirroring the tray tile's Show + Dismiss split: close-and-keep (corner) vs dismiss-and-remove (button). OS-461 Co-Authored-By: Claude Opus 4.8 --- .../include/DefaultPageLayout/BodyInlineJS.php | 11 ++++++++--- .../include/DefaultPageLayout/HeadInlineJS.php | 8 ++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index e348ed53e..6aeca1eac 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -233,10 +233,15 @@ function foregroundTask(id) { trayRender(); }); $('.sweet-alert').addClass('nchan'); - // a top-corner minimize for the running phase: backgrounds the task (it keeps - // running in the tray) without aborting; openDone/openError swap it for Dismiss + // 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. + var closeIcon = finished ? 'fa-times' : 'fa-window-minimize'; + var closeTip = finished ? "" : ""; $('.sweet-alert .nchan-close').remove(); - if (!finished) $('.sweet-alert').append("\" onclick='minimizeForegroundTask()'>"); + $('.sweet-alert').append(""); $('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 5e2859919..bdc294498 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -248,9 +248,9 @@ function openAlert(cmd,title,func) { function openDone(data) { if (data == '_DONE_') { $('div.spinner.fixed').hide(); - // task finished: the running-phase minimize control gives way to a primary - // Dismiss button (clears the task from the tray on confirm) - $('.sweet-alert .nchan-close').remove(); + // task finished: the corner control becomes a plain "close" (the task stays + // in the tray); the primary Dismiss button removes it on confirm + $('.sweet-alert .nchan-close').attr('title',"").find('i').attr('class','fa fa-times fa-fw'); $('button.confirm').text("").prop('disabled',false).show(); if (typeof ca_done_override !== 'undefined') { if (ca_done_override == true) { @@ -267,7 +267,7 @@ function openDone(data) { function openError(data) { if (data == '_ERROR_') { $('div.spinner.fixed').hide(); - $('.sweet-alert .nchan-close').remove(); + $('.sweet-alert .nchan-close').attr('title',"").find('i').attr('class','fa fa-times fa-fw'); $('button.confirm').text("").prop('disabled',false).show(); $('#pluginProgressTitle').text(""); return true; From dafaa4453d7f718ccbb5b831ac0658513b1410dd Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 20:11:32 -0400 Subject: [PATCH 05/31] style(task-tray): make the modal minimize/close a clearer button The control was colored with --text-color at 60% opacity, but the modal is a dark surface (.sweet-alert bg = --black), so on the default theme it was dark-on-dark and easy to miss. Make it a light, bordered circular button with a brand-orange hover, bump the hit area (and again on mobile for touch), and use a cleaner fa-minus for the running/minimize glyph (fa-times stays for close). OS-461 Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/BodyInlineJS.php | 2 +- .../plugins/dynamix/styles/default-base.css | 31 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 6aeca1eac..1790cf2f9 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -238,7 +238,7 @@ function foregroundTask(id) { // 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. - var closeIcon = finished ? 'fa-times' : 'fa-window-minimize'; + var closeIcon = finished ? 'fa-times' : 'fa-minus'; var closeTip = finished ? "" : ""; $('.sweet-alert .nchan-close').remove(); $('.sweet-alert').append(""); diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index b50da313b..384cfc4b0 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1955,20 +1955,35 @@ label.checkbox input:disabled ~ .checkmark { text-decoration: none; } .op-tray .op-act:hover { color: var(--brand-orange); } -/* top-corner minimize on the foreground task modal (backgrounds the task) */ +/* top-corner minimize/close on the foreground task modal. The modal is a dark + surface, so the control is a light, bordered circular button (not a faint + theme-colored glyph) with a brand-orange hover so it reads clearly as an + action. */ .sweet-alert .nchan-close { position: absolute; - top: .6rem; - right: .9rem; - font-size: 1.3rem; + top: .8rem; + right: .8rem; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 1.4rem; line-height: 1; - color: var(--text-color); - opacity: .6; + color: rgba(255,255,255,.85); + background: rgba(255,255,255,.1); + border: 1px solid rgba(255,255,255,.28); cursor: pointer; text-decoration: none; + transition: background-color .15s, border-color .15s, color .15s; z-index: 2; } -.sweet-alert .nchan-close:hover { opacity: 1; color: var(--brand-orange); } +.sweet-alert .nchan-close:hover { + color: #fff; + background: var(--brand-orange); + border-color: var(--brand-orange); +} /* Mobile: the foreground task modal goes fullscreen instead of a centered, rounded card (which looks cramped/"circular" on small screens). Scoped to .nchan so ordinary confirm/warning dialogs keep their dialog look. The title @@ -2005,7 +2020,7 @@ label.checkbox input:disabled ~ .checkmark { text-align: left; } .sweet-alert.nchan .sa-button-container { flex: 0 0 auto; margin-top: 1rem; } - .sweet-alert.nchan .nchan-close { top: 1.1rem; right: 1.1rem; font-size: 1.7rem; opacity: .85; } + .sweet-alert.nchan .nchan-close { top: 1rem; right: 1rem; width: 2.9rem; height: 2.9rem; font-size: 1.6rem; } } .back_to_top { right: 40px; From 1fbad7d7d7fa00799f022143a0007dd197ea607e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 20:28:35 -0400 Subject: [PATCH 06/31] feat(task-tray): restyle the task modal as a themed right-docked sheet Align the foreground task modal with the Unraid notifications sidebar: dock it to the right as a full-height sheet (full-width on mobile), shrink the header and move the status onto a muted subline, put the log in a card, and give it a brand-orange primary Dismiss button. Critically, all colors now come from theme tokens (--dynamix-sweet-alert-*, --alt-text-color, --shade-bg-color, --border-color, --brand-orange) instead of hardcoded light values, so it adapts correctly across white / azure / black / gray. (azure/white override the sweet-alert tokens to a LIGHT surface, which is why the previous hardcoded-white close icon was invisible.) OS-461 Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/BodyInlineJS.php | 2 +- .../plugins/dynamix/styles/default-base.css | 145 +++++++++++++----- 2 files changed, 104 insertions(+), 43 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 1790cf2f9..ee36707ee 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -222,7 +222,7 @@ function foregroundTask(id) { var titleState = task.status=='done' ? "" : task.status=='error' ? "" : " "; - swal({title:escapeTaskHtml(task.title) + ' - '+titleState+'',text:"

",html:true,animation:'none',showConfirmButton:finished,confirmButtonText:""},function(close){ + swal({title:escapeTaskHtml(task.title) + ''+titleState+'',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(); diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 384cfc4b0..629e55f3e 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1955,72 +1955,133 @@ label.checkbox input:disabled ~ .checkmark { text-decoration: none; } .op-tray .op-act:hover { color: var(--brand-orange); } -/* top-corner minimize/close on the foreground task modal. The modal is a dark - surface, so the control is a light, bordered circular button (not a faint - theme-colored glyph) with a brand-orange hover so it reads clearly as an - action. */ +/* The foreground task modal is restyled as a right-docked sidebar/sheet to mirror + the Unraid notifications sidebar. Every color is a theme token so it adapts to + white / azure / black / gray: + surface = --dynamix-sweet-alert-icon-bg-color (the modal's own themed bg) + text = --dynamix-sweet-alert-text-color muted = --alt-text-color + card = --shade-bg-color lines = --border-color accent = --brand-orange */ +.sweet-alert.nchan { + display: flex !important; + flex-direction: column; + left: auto; + right: 0; + top: 0; + bottom: 0; + transform: none; + -webkit-transform: none; + width: 34rem; + max-width: 92vw; + height: 100vh; + height: 100dvh; + max-height: 100vh; + max-height: 100dvh; + margin: 0; + padding: 0; + border: 0; + border-left: 1px solid var(--border-color); + border-radius: 0; + box-shadow: -0.5rem 0 1.5rem var(--bg-opacity-30); + text-align: left; + overflow: hidden; + box-sizing: border-box; + animation: nchanSlideIn .22s ease-out; +} +@keyframes nchanSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } } +.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; + border-bottom: 1px solid var(--border-color); +} +.sweet-alert.nchan #pluginProgressTitle { + display: block; + margin-top: 0.3rem; + font-size: 1.15rem; + font-weight: 400; + color: var(--alt-text-color); +} +.sweet-alert.nchan > p { + flex: 1 1 auto; + 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.8rem 0.9rem; + text-align: left; + font-size: 1.2rem; + background: var(--shade-bg-color); + border: 1px solid var(--border-color); + border-radius: 0.4rem; +} +.sweet-alert.nchan .sa-button-container { + flex: 0 0 auto; + margin: 0; + padding: 0.9rem 1.2rem; + border-top: 1px solid var(--border-color); + text-align: left; +} +.sweet-alert.nchan button.confirm { + margin: 0; + padding: 0.6rem 1.5rem; + font-size: 1.3rem; + color: var(--white); + background: var(--brand-orange); + border: 0; + border-radius: 0.4rem; + box-shadow: none; +} +.sweet-alert.nchan button.confirm:hover { background: var(--orange-800); } +/* 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: .8rem; - right: .8rem; + 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.4rem; + font-size: 1.3rem; line-height: 1; - color: rgba(255,255,255,.85); - background: rgba(255,255,255,.1); - border: 1px solid rgba(255,255,255,.28); + 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: #fff; + color: var(--white); background: var(--brand-orange); border-color: var(--brand-orange); } -/* Mobile: the foreground task modal goes fullscreen instead of a centered, - rounded card (which looks cramped/"circular" on small screens). Scoped to - .nchan so ordinary confirm/warning dialogs keep their dialog look. The title - stays pinned at the top, the log fills and scrolls, and the minimize / Dismiss - controls stay reachable at the corners/bottom. */ +/* Mobile: full-width sheet (flush, still slides in from the right). */ @media (max-width: 767px) { .sweet-alert.nchan { - display: flex !important; - flex-direction: column; left: 0; - top: 0; + right: 0; width: 100vw; max-width: 100vw; - height: 100vh; - height: 100dvh; - max-height: 100vh; - max-height: 100dvh; - margin: 0; - padding: 1.25rem 1rem; - border: 0; - border-radius: 0; - transform: none !important; - -webkit-transform: none !important; - box-sizing: border-box; - } - .sweet-alert.nchan > h2 { flex: 0 0 auto; padding-right: 2.5rem; } - .sweet-alert.nchan > p { flex: 1 1 auto; min-height: 0; display: flex; margin: 0; } - .sweet-alert.nchan #swaltext { - flex: 1 1 auto; - min-height: 0; - max-height: none; - overflow: auto; - margin: 0; - text-align: left; + border-left: 0; } - .sweet-alert.nchan .sa-button-container { flex: 0 0 auto; margin-top: 1rem; } - .sweet-alert.nchan .nchan-close { top: 1rem; right: 1rem; width: 2.9rem; height: 2.9rem; font-size: 1.6rem; } + .sweet-alert.nchan .nchan-close { width: 2.9rem; height: 2.9rem; font-size: 1.6rem; } } .back_to_top { right: 40px; From a552cb3f8c036d4cbb74730b81f72d28084fde2e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 20:33:10 -0400 Subject: [PATCH 07/31] style(task-tray): widen the task sheet and force full viewport height Bump the docked sheet to 46rem (max 95vw) and pin min/height to 100dvh + top/bottom:0 so it always fills the available height. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 629e55f3e..45951ad18 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1970,10 +1970,12 @@ label.checkbox input:disabled ~ .checkmark { bottom: 0; transform: none; -webkit-transform: none; - width: 34rem; - max-width: 92vw; + width: 46rem; + max-width: 95vw; height: 100vh; height: 100dvh; + min-height: 100vh; + min-height: 100dvh; max-height: 100vh; max-height: 100dvh; margin: 0; From d0b0d81b2e756a0d092714a22858bbc63bdd13c0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 20:37:33 -0400 Subject: [PATCH 08/31] style(task-tray): revert to a compact centered modal (keep behavior) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the desktop form back from a full-height right sidebar to a compact, centered card — a transient progress log shouldn't take over the screen, and a familiar centered modal is lower-risk for the docker/plugin update flow. All the substance stays: smaller header + muted status subline, themed log card, brand-orange Dismiss, minimize/close semantics, theme-token colors, and mobile fullscreen. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/styles/default-base.css | 50 ++++++++----------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 45951ad18..d61c44a56 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1955,41 +1955,27 @@ label.checkbox input:disabled ~ .checkmark { text-decoration: none; } .op-tray .op-act:hover { color: var(--brand-orange); } -/* The foreground task modal is restyled as a right-docked sidebar/sheet to mirror - the Unraid notifications sidebar. Every color is a theme token so it adapts to - white / azure / black / gray: - surface = --dynamix-sweet-alert-icon-bg-color (the modal's own themed bg) - text = --dynamix-sweet-alert-text-color muted = --alt-text-color - card = --shade-bg-color lines = --border-color accent = --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; - left: auto; - right: 0; - top: 0; - bottom: 0; - transform: none; - -webkit-transform: none; - width: 46rem; - max-width: 95vw; - height: 100vh; - height: 100dvh; - min-height: 100vh; - min-height: 100dvh; - max-height: 100vh; - max-height: 100dvh; + width: 90vw; + max-width: 42rem; + max-height: 85vh; margin: 0; padding: 0; - border: 0; - border-left: 1px solid var(--border-color); - border-radius: 0; - box-shadow: -0.5rem 0 1.5rem var(--bg-opacity-30); text-align: left; overflow: hidden; box-sizing: border-box; - animation: nchanSlideIn .22s ease-out; + box-shadow: 0 0.5rem 2rem var(--bg-opacity-30); } -@keyframes nchanSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } } .sweet-alert.nchan > h2 { flex: 0 0 auto; margin: 0; @@ -2074,14 +2060,22 @@ label.checkbox input:disabled ~ .checkmark { background: var(--brand-orange); border-color: var(--brand-orange); } -/* Mobile: full-width sheet (flush, still slides in from the right). */ +/* 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; - border-left: 0; + 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; } } From 83d3020b41e875451deaba48299eee6178d2af68 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 20:42:40 -0400 Subject: [PATCH 09/31] fix(task-tray): make the Dismiss button visible (override inline transparent) SweetAlert sets background-color:transparent inline on the confirm button (default confirmButtonColor), which beat the CSS class and left the orange Dismiss button white-on-white. Override bg/color/border with !important. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index d61c44a56..b32522e6f 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2022,17 +2022,19 @@ label.checkbox input:disabled ~ .checkmark { border-top: 1px solid var(--border-color); text-align: left; } +/* !important overrides SweetAlert's inline `background-color: transparent` + (its default confirmButtonColor), which otherwise leaves white-on-white. */ .sweet-alert.nchan button.confirm { margin: 0; padding: 0.6rem 1.5rem; font-size: 1.3rem; - color: var(--white); - background: var(--brand-orange); - border: 0; + color: var(--white) !important; + background: var(--brand-orange) !important; + border: 0 !important; border-radius: 0.4rem; - box-shadow: none; + box-shadow: none !important; } -.sweet-alert.nchan button.confirm:hover { background: var(--orange-800); } +.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 { From af3043e59f7320752c3b0abf05630fa2971fe0b6 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 21:33:08 -0400 Subject: [PATCH 10/31] fix(task-tray): keep scroll buttons clear of the tray + mobile swipe carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll-to-top/bottom buttons and the task tray both sit bottom-right at the same z-index, so the buttons covered the task tiles. Raise the tray above them and, when the tray has tasks, move the buttons clear (bottom-left on desktop, lifted above the tray on mobile) via the #opTray sibling selector. On mobile the tray is now a full-width horizontal scroll-snap carousel — one task tile at a time, swipe between them — instead of a bottom-right stack. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/styles/default-base.css | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index b32522e6f..7cba10496 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1897,7 +1897,7 @@ label.checkbox input:disabled ~ .checkmark { display: flex; flex-direction: column; gap: .25rem; - z-index: 999; + z-index: 1000; } /* Lift the tray above the fixed footer so it isn't clipped behind it. Mirrors the media queries that make #footer position:fixed. */ @@ -2087,6 +2087,41 @@ label.checkbox input:disabled ~ .checkmark { .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 becomes a full-width, swipeable carousel of task tiles (one + at a time) instead of a bottom-right stack, and the scroll buttons lift above + it so they never cover a tile. */ +@media (max-width: 767px) { + .op-tray { + left: 1rem; + right: 1rem; + width: auto; + max-width: none; + max-height: none; + flex-flow: row nowrap; + overflow-x: auto; + overflow-y: hidden; + gap: .5rem; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: 2px; + } + .op-tray::-webkit-scrollbar { display: none; } + .op-tray .op-task, + .op-tray .op-tray-head { + flex: 0 0 100%; + scroll-snap-align: center; + box-sizing: border-box; + } + .op-tray:not(:empty) ~ .back_to_top { left: auto; right: 40px; bottom: 6rem; } + .op-tray:not(:empty) ~ .move_to_end { left: auto; right: 12px; bottom: 6rem; } +} span.big.blue-text { cursor: pointer; } From c19c0e7e24dc2fd9a7ddd0f94ed96fc90b9856d3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 21:35:21 -0400 Subject: [PATCH 11/31] fix(task-tray): keep one consistent minimize icon (drop the minus->x swap) The corner control flipped from minimize (-) while running to a close (x) when done, but that 'x' still only minimized (kept the task in the tray) while the Dismiss button was the real remove. Confusing. Keep the corner as a single, consistent minimize in every state; Dismiss remains the close-and-remove. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../dynamix/include/DefaultPageLayout/BodyInlineJS.php | 7 +++++-- .../dynamix/include/DefaultPageLayout/HeadInlineJS.php | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index ee36707ee..d4d77ca51 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -238,8 +238,11 @@ function foregroundTask(id) { // 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. - var closeIcon = finished ? 'fa-times' : 'fa-minus'; - var closeTip = finished ? "" : ""; + // 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(''); diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index bdc294498..bb3dd4aaa 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -248,9 +248,9 @@ function openAlert(cmd,title,func) { function openDone(data) { if (data == '_DONE_') { $('div.spinner.fixed').hide(); - // task finished: the corner control becomes a plain "close" (the task stays - // in the tray); the primary Dismiss button removes it on confirm - $('.sweet-alert .nchan-close').attr('title',"").find('i').attr('class','fa fa-times fa-fw'); + // 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',""); $('button.confirm').text("").prop('disabled',false).show(); if (typeof ca_done_override !== 'undefined') { if (ca_done_override == true) { @@ -267,7 +267,7 @@ function openDone(data) { function openError(data) { if (data == '_ERROR_') { $('div.spinner.fixed').hide(); - $('.sweet-alert .nchan-close').attr('title',"").find('i').attr('class','fa fa-times fa-fw'); + $('.sweet-alert .nchan-close').attr('title',""); $('button.confirm').text("").prop('disabled',false).show(); $('#pluginProgressTitle').text(""); return true; From 69f8d7210382e7a83c9addb71955daca9af297cc Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 21:57:34 -0400 Subject: [PATCH 12/31] fix(task-tray): stop the minimize flash; restore wider modal on large screens - Close: fade the modal out with .nchan still applied, then strip the class after (~350ms) via a shared nchanCloseModal(). Removing .nchan while visible snapped it back to the default swal look for a frame (the flash). Guard with pointer-events:none and a reopen check. - Width: bump max-width 42rem -> 60rem so it's as wide as it used to be on large screens (still 90vw on smaller ones). OS-461 Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/BodyInlineJS.php | 22 +++++++++++++++---- .../plugins/dynamix/styles/default-base.css | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index d4d77ca51..faac62597 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -227,12 +227,12 @@ function foregroundTask(id) { 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); dismissTask(id); } + nchanCloseModal(false); // swal closes via closeOnConfirm; this just cleans up trayRender(); }); - $('.sweet-alert').addClass('nchan'); + $('.sweet-alert').addClass('nchan').css('pointer-events',''); // 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 @@ -327,12 +327,26 @@ function trayRender() { // 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(); - $('.sweet-alert').removeClass('nchan'); - if (typeof swal!=='undefined' && swal.close) swal.close(); + nchanCloseModal(true); trayRender(); } diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 7cba10496..ecc16ca9a 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1967,7 +1967,7 @@ label.checkbox input:disabled ~ .checkmark { display: flex !important; flex-direction: column; width: 90vw; - max-width: 42rem; + max-width: 60rem; max-height: 85vh; margin: 0; padding: 0; From 708e3618d6c7f130d307ffa71606042161790748 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:04:02 -0400 Subject: [PATCH 13/31] style(task-tray): slim footer, full-width Dismiss (drop the centered button) The Dismiss button was centered (swal's .sa-confirm-button-container centers it) in a tall footer (margin-top + a hidden loader reserving space). Make it a full-width button in a slim footer: deliberate, less vertical space, consistent desktop/mobile. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index ecc16ca9a..b95bf40f1 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2015,19 +2015,24 @@ label.checkbox input:disabled ~ .checkmark { border: 1px solid var(--border-color); border-radius: 0.4rem; } +/* 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; margin: 0; - padding: 0.9rem 1.2rem; + padding: 0.7rem 1.2rem; border-top: 1px solid var(--border-color); - text-align: left; } -/* !important overrides SweetAlert's inline `background-color: transparent` - (its default confirmButtonColor), which otherwise leaves white-on-white. */ +.sweet-alert.nchan .sa-confirm-button-container { display: block; } +.sweet-alert.nchan .la-ball-fall { display: none; } +/* !important overrides SweetAlert's inline background-color:transparent / sizing */ .sweet-alert.nchan button.confirm { + display: block; + width: 100%; margin: 0; - padding: 0.6rem 1.5rem; - font-size: 1.3rem; + padding: 0.6rem 1rem; + font-size: 1.25rem; color: var(--white) !important; background: var(--brand-orange) !important; border: 0 !important; From 3d25fcec3a34ade87bcf3ec343bdc2d1007e0703 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:04:56 -0400 Subject: [PATCH 14/31] fix(task-tray): drop the stray 40px bottom padding on running modal SweetAlert adds padding-bottom:40px when no buttons are shown (the running state has no footer button), and that selector outspecified .nchan { padding:0 }. Override it with a matching higher-specificity rule. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index b95bf40f1..537883d15 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1976,6 +1976,12 @@ label.checkbox input:disabled ~ .checkmark { 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; +} .sweet-alert.nchan > h2 { flex: 0 0 auto; margin: 0; From 0e66806a19979b0ca5599f88f23c0fc51eb941c8 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:05:29 -0400 Subject: [PATCH 15/31] style(task-tray): right-align the Dismiss button (conventional dialog placement) Switch from full-width to a compact bottom-right Dismiss - lighter, more dialog-like. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 537883d15..9479413ab 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2030,14 +2030,14 @@ label.checkbox input:disabled ~ .checkmark { padding: 0.7rem 1.2rem; border-top: 1px solid var(--border-color); } -.sweet-alert.nchan .sa-confirm-button-container { display: block; } +.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: block; - width: 100%; + display: inline-block; + width: auto; margin: 0; - padding: 0.6rem 1rem; + padding: 0.5rem 1.4rem; font-size: 1.25rem; color: var(--white) !important; background: var(--brand-orange) !important; From d1e170a36708d52ae8b03b4763644319fec821f4 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:08:30 -0400 Subject: [PATCH 16/31] fix(task-tray): right-align Dismiss for real (outer button-container was centering) .sa-button-container is itself a flex container with justify-content:center, and the inner confirm-container is only button-width, so right-aligning the inner one did nothing. Right-align the outer container instead. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 9479413ab..2ac3b57e7 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2026,6 +2026,8 @@ label.checkbox input:disabled ~ .checkmark { 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); From d9ab361df1d95768411662dc9ba37f89603667e1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:13:19 -0400 Subject: [PATCH 17/31] style(task-tray): drop the log card border/fill (too many borders) The log sat in its own bordered, filled box inside the modal that already has a header divider and footer divider - a box-in-a-box. Make the log a plain region framed by the dividers (header / body / footer). OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 2ac3b57e7..55eaac710 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2014,12 +2014,9 @@ label.checkbox input:disabled ~ .checkmark { max-height: none; overflow: auto; margin: 0; - padding: 0.8rem 0.9rem; + padding: 0; text-align: left; font-size: 1.2rem; - background: var(--shade-bg-color); - border: 1px solid var(--border-color); - border-radius: 0.4rem; } /* 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 From 5a253603a88b76896a7c9e9b1bbbfaef56ee9d1b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:16:44 -0400 Subject: [PATCH 18/31] fix(task-tray): hide the footer bar while running (no button = no empty strip) The running modal has no Dismiss button, but the footer container still rendered its top border + padding as an empty bottom bar. Hide it when no confirm button. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 55eaac710..d0c25b47a 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1982,6 +1982,11 @@ label.checkbox input:disabled ~ .checkmark { .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; From a69f511818208984c58be2b4175586b62138b6cd Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:19:38 -0400 Subject: [PATCH 19/31] style(task-tray): size the modal to its log instead of stretching to 85vh The log used flex:1 1 auto and stretched the modal full height, leaving a big empty gap for short logs. Use flex:0 1 auto so the modal hugs its content and only grows/scrolls once the log exceeds the 85vh cap. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index d0c25b47a..21691cde0 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -2005,7 +2005,7 @@ label.checkbox input:disabled ~ .checkmark { color: var(--alt-text-color); } .sweet-alert.nchan > p { - flex: 1 1 auto; + flex: 0 1 auto; /* size to the log, don't stretch the modal */ min-height: 0; margin: 0; padding: 1rem 1.2rem; @@ -2014,7 +2014,7 @@ label.checkbox input:disabled ~ .checkmark { text-align: left; } .sweet-alert.nchan #swaltext { - flex: 1 1 auto; + flex: 0 1 auto; min-height: 0; max-height: none; overflow: auto; From 1b09224bbf8e41a3b3c93af1804180bb354b1dc0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:23:42 -0400 Subject: [PATCH 20/31] feat(task-tray): show task status as a colored state strip below the title Move the In Progress/Finished/Error status out of the title subline into a distinct, colored state section between the title and the log: blue while running, green when finished, red on error (with an icon). openDone/openError recolor it. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/BodyInlineJS.php | 14 +++++++++---- .../DefaultPageLayout/HeadInlineJS.php | 4 ++-- .../plugins/dynamix/styles/default-base.css | 20 +++++++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index faac62597..96b1fb88e 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -219,10 +219,13 @@ function foregroundTask(id) { // 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'; - var titleState = task.status=='done' ? "" - : task.status=='error' ? "" - : " "; - swal({title:escapeTaskHtml(task.title) + ''+titleState+'',text:"

",html:true,animation:'none',showConfirmButton:finished,confirmButtonText:""},function(close){ + // 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(); @@ -233,6 +236,9 @@ function foregroundTask(id) { 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+"
"); // 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 diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index bb3dd4aaa..6ba3a4d14 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -258,7 +258,7 @@ function openDone(data) { ca_done_override = false; } } - $('#pluginProgressTitle').text(""); + $('#pluginProgressTitle').attr('class','nchan-state nchan-done').html(" "); return true; } return false; @@ -269,7 +269,7 @@ function openError(data) { $('div.spinner.fixed').hide(); $('.sweet-alert .nchan-close').attr('title',""); $('button.confirm').text("").prop('disabled',false).show(); - $('#pluginProgressTitle').text(""); + $('#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 21691cde0..1e1c74a71 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1995,15 +1995,23 @@ label.checkbox input:disabled ~ .checkmark { line-height: 1.35; font-weight: 600; text-align: left; - border-bottom: 1px solid var(--border-color); } -.sweet-alert.nchan #pluginProgressTitle { - display: block; - margin-top: 0.3rem; +/* colored state strip below the title: a distinct status section, not a subline */ +.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.15rem; - font-weight: 400; - color: var(--alt-text-color); + font-weight: 500; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); } +.sweet-alert.nchan .nchan-state.nchan-running { background: var(--blue-100); color: var(--blue-900); } +.sweet-alert.nchan .nchan-state.nchan-done { background: var(--green-100); color: var(--green-900); } +.sweet-alert.nchan .nchan-state.nchan-error { background: var(--red-100); color: var(--red-900); } .sweet-alert.nchan > p { flex: 0 1 auto; /* size to the log, don't stretch the modal */ min-height: 0; From 255647e78b6b2d9361abd8e47f3194c6fe431ef9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:29:14 -0400 Subject: [PATCH 21/31] fix(task-tray): modal hugs content (override swal 95vh) + modern state strip - height: the swal @media(max-width:1200px){height:95vh} forced the modal tall, pooling empty space below the footer. Set .nchan height:auto so it sizes to content (capped at 85vh, scrolls beyond). Dismiss now sits at the real bottom. - state strip: drop the dated pale-color banner for a subtle translucent tint + colored icon + theme (--text-color) text, readable on light and dark. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/styles/default-base.css | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 1e1c74a71..587de3e6d 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1968,6 +1968,7 @@ label.checkbox input:disabled ~ .checkmark { flex-direction: column; width: 90vw; max-width: 60rem; + height: auto; /* override swal's @media(max-width:1200px){height:95vh} so the modal hugs its content */ max-height: 85vh; margin: 0; padding: 0; @@ -1996,7 +1997,9 @@ label.checkbox input:disabled ~ .checkmark { font-weight: 600; text-align: left; } -/* colored state strip below the title: a distinct status section, not a subline */ +/* 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; @@ -2004,14 +2007,18 @@ label.checkbox input:disabled ~ .checkmark { gap: 0.5rem; margin: 0; padding: 0.5rem 1.2rem; - font-size: 1.15rem; + font-size: 1.1rem; font-weight: 500; - border-top: 1px solid var(--border-color); + color: var(--text-color); border-bottom: 1px solid var(--border-color); } -.sweet-alert.nchan .nchan-state.nchan-running { background: var(--blue-100); color: var(--blue-900); } -.sweet-alert.nchan .nchan-state.nchan-done { background: var(--green-100); color: var(--green-900); } -.sweet-alert.nchan .nchan-state.nchan-error { background: var(--red-100); color: var(--red-900); } +.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: 0 1 auto; /* size to the log, don't stretch the modal */ min-height: 0; From f609af4232a12a7c94f5df0221d022b0a87ec04f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:40:28 -0400 Subject: [PATCH 22/31] style(task-tray): fixed modal height so the Dismiss button stays put Per feedback: a stable button beats a modal that grows and shifts as the log streams. Fixed height 34rem (capped at 85vh on short screens); the log fills and scrolls in place, footer pinned to the bottom. Mobile stays fullscreen. OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 587de3e6d..284abb317 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1968,8 +1968,8 @@ label.checkbox input:disabled ~ .checkmark { flex-direction: column; width: 90vw; max-width: 60rem; - height: auto; /* override swal's @media(max-width:1200px){height:95vh} so the modal hugs its content */ - max-height: 85vh; + height: 34rem; /* fixed, moderate height (overrides swal's @media 95vh) so the log scrolls in place and the Dismiss button never moves */ + max-height: 85vh; /* but don't exceed the viewport on short screens */ margin: 0; padding: 0; text-align: left; @@ -2020,7 +2020,7 @@ label.checkbox input:disabled ~ .checkmark { .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: 0 1 auto; /* size to the log, don't stretch the modal */ + 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; @@ -2029,7 +2029,7 @@ label.checkbox input:disabled ~ .checkmark { text-align: left; } .sweet-alert.nchan #swaltext { - flex: 0 1 auto; + flex: 1 1 auto; min-height: 0; max-height: none; overflow: auto; From cd07b30e98bb68c444f4d1a895ba47beeb0458ee Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:42:29 -0400 Subject: [PATCH 23/31] style(task-tray): bump fixed modal height to 75vh (34rem felt short) OS-461 Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/styles/default-base.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 284abb317..4a46e81db 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1968,8 +1968,8 @@ label.checkbox input:disabled ~ .checkmark { flex-direction: column; width: 90vw; max-width: 60rem; - height: 34rem; /* fixed, moderate height (overrides swal's @media 95vh) so the log scrolls in place and the Dismiss button never moves */ - max-height: 85vh; /* but don't exceed the viewport on short screens */ + 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; From b89e6535173d0de4e8e25efa8eb293ebe650ca10 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 22:43:39 -0400 Subject: [PATCH 24/31] fix(task-tray): show Dismiss immediately when a task finishes in-place The modal opens running with showConfirmButton:false (data-has-confirm-button= false), and the footer is hidden by CSS keyed on that attribute. openDone/openError showed the button but never flipped the attribute, so the footer stayed hidden until the modal was reopened. Set data-has-confirm-button=true on completion. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 6ba3a4d14..37e5569d3 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -251,6 +251,7 @@ function openDone(data) { // 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) { @@ -268,6 +269,7 @@ function openError(data) { if (data == '_ERROR_') { $('div.spinner.fixed').hide(); $('.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; From 68e0ec61947b5e2d5831de4357c4f63a672bfc5f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 23:21:24 -0400 Subject: [PATCH 25/31] feat(plugins): show 'Upgrading' (disabled) while a plugin task is running Minimizing the progress modal during a plugin update left the row's Update button visible and clickable (stale) - you could start a duplicate update. Drive the button off the live task queue: while a running/queued plugins task targets a plugin, its update/install button shows 'Upgrading' and is disabled. Survives modal minimize, list reloads and page reloads via a window.onTaskListChanged hook fired from onTaskListUpdate. Also align the in-progress modal status line with the new state strip. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../dynamix.plugin.manager/Plugins.page | 26 +++++++++++++++++++ .../DefaultPageLayout/BodyInlineJS.php | 5 +++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index e4a30d177..2d027de04 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -141,8 +141,34 @@ function loadlist(id,check) { } $('#plugin_table').trigger('update'); $('#checkall').find('input').prop('disabled',false); + syncPluginButtons(); }); } +// Reflect in-flight plugin tasks on the row buttons: while the backend task queue +// has a running/queued task for a plugin, show its update/install button as +// "Upgrading" and disabled, so minimizing the progress modal can't leave a stale, +// clickable button. Driven by the live task list, so it survives modal minimize, +// list reloads and full page reloads. +function pluginTaskRunning(arg) { + if (typeof taskList === 'undefined' || !arg) return false; + for (var i=0;i=0) return true; + } + return false; +} +function syncPluginButtons() { + $('#plugin_table input.update, #plugin_table input.install').each(function(){ + var $b = $(this), arg = $b.attr('data'); + if (pluginTaskRunning(arg)) { + if ($b.data('busy')===undefined) $b.data('busy', $b.val()); + $b.prop('disabled', true).val("_(Upgrading)_"); + } else if ($b.data('busy')!==undefined) { + $b.prop('disabled', false).val($b.data('busy')).removeData('busy'); + } + }); +} +window.onTaskListChanged = syncPluginButtons; $(function() { initlist(); $('.tabs-container').append(""); diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 96b1fb88e..006f824e2 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -281,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]); } } @@ -289,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}); From 0624f195273a1a5163f5b897c49972a2f13c5163 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 18 Jun 2026 23:56:47 -0400 Subject: [PATCH 26/31] refactor(plugins): drive the Upgrading button state from the server (task queue) Replace the client-side string-matching/busy-toggle with a single source of truth: make_link() now renders 'Upgrading'/'Installing' (disabled) when the task queue has a running/queued task for that .plg (plugin_task_busy via task_list, precise token match). The Plugins page just triggers a cheap status-cell refresh (loadlist(null,1), no network re-check) when the active plugin-task set changes. Correct on every render path - page load, reload, completion - and generalizes better than the per-page client logic. OS-461 Co-Authored-By: Claude Opus 4.8 --- .../dynamix.plugin.manager/Plugins.page | 39 +++++++------------ .../include/PluginHelpers.php | 17 +++++++- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index 2d027de04..fe959fca3 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -141,34 +141,25 @@ function loadlist(id,check) { } $('#plugin_table').trigger('update'); $('#checkall').find('input').prop('disabled',false); - syncPluginButtons(); }); } -// Reflect in-flight plugin tasks on the row buttons: while the backend task queue -// has a running/queued task for a plugin, show its update/install button as -// "Upgrading" and disabled, so minimizing the progress modal can't leave a stale, -// clickable button. Driven by the live task list, so it survives modal minimize, -// list reloads and full page reloads. -function pluginTaskRunning(arg) { - if (typeof taskList === 'undefined' || !arg) return false; - for (var i=0;i=0) return true; - } - return false; -} -function syncPluginButtons() { - $('#plugin_table input.update, #plugin_table input.install').each(function(){ - var $b = $(this), arg = $b.attr('data'); - if (pluginTaskRunning(arg)) { - if ($b.data('busy')===undefined) $b.data('busy', $b.val()); - $b.prop('disabled', true).val("_(Upgrading)_"); - } else if ($b.data('busy')!==undefined) { - $b.prop('disabled', false).val($b.data('busy')).removeData('busy'); +// 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 +// 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. +var _plgTaskSig = ''; +function onPluginTasksChanged() { + var sig = ''; + if (typeof taskList !== 'undefined') { + for (var i=0;i
"); diff --git a/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php b/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php index 95428cb64..45b0f0bab 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"; From 4a80c6642f9062fc263656d0c3986269f2fdc985 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 19 Jun 2026 00:18:32 -0400 Subject: [PATCH 27/31] fix(plugins): add Markdown="false" so the Plugins page script survives rendering The tabbed content renderer (generateContent) Markdown-processes a page body unless the header sets Markdown="false". Plugins.page lacked it (unlike the script-heavy Docker/VM pages), so its inline