diff --git a/.codex/coderabbit-fixes-wip.md b/.codex/coderabbit-fixes-wip.md new file mode 100644 index 0000000000..b24ef5b01f --- /dev/null +++ b/.codex/coderabbit-fixes-wip.md @@ -0,0 +1,60 @@ +# CodeRabbit Fixes WIP + +## Context + +- Repo: unraid/webgui +- Branch: feat/backend-task-queue +- PR: 2665 +- PR URL: https://github.com/unraid/webgui/pull/2665 +- Generated at: 2026-06-18 + +## Inputs Pulled + +- [x] Unresolved robot review threads pulled (7) +- [x] Top-level robot review notes and PR conversation comments pulled +- [x] Top-level actionable review-body/PR comments extracted into queue (5 nitpicks) +- [x] User asked whether to include human review comments +- [x] Human review comments included in queue: no (user chose robot-only) +- [x] Existing non-CodeRabbit thread replies checked before adding duplicate feedback (none from bots/humans; only a github-actions test-plugin notice) + +## Fix Queue + +| Item ID | Type | File | Line | Summary | Status | Link | Evidence | +| --- | --- | --- | --- | --- | --- | --- | --- | +| CR-001 | thread | BodyInlineJS.php | 214 | XSS in swal title via task.title (html:true) | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502042 | escapeTaskHtml(task.title) in swal title; php -l OK | +| CR-002 | thread | BodyInlineJS.php | 279-291 | t.id unescaped in onclick handlers | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502054 | safeId=escapeTaskHtml(t.id) used in all 5 handlers; php -l OK | +| CR-003 | thread | HeadInlineJS.php | 183-192 | createTask silent failure on POST error | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502071 | .fail() hides spinner + error swal; php -l OK | +| CR-004 | thread | TaskQueue.php | 123 | Command injection via unescaped $args in bash -c | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502088 | escapeshellarg() over whole bash -c payload (preserves multi-arg word-split); reply posted explaining deviation from literal suggestion; php -l OK | +| CR-005 | thread | nchan/tasks | 27-30 | Empty/non-numeric PID breaks /proc liveness check | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502115 | ctype_digit() guard before file_exists; php -l OK | +| CR-006 | thread | default-base.css | 1919-1924 | Add min-width:0 for reliable ellipsis | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3436502123 | min-width:0 added to .op-tray .op-title | +| CR-007 | thread | nchan/tasks | 62-67 | Daemon sleeps forever on queued-only restart | DONE | https://github.com/unraid/webgui/pull/2665#discussion_r3437357364 | queued-without-running recovery pass before advance/active check; php -l OK | +| RVW-001 | review-body | TaskQueue.php | 147-177 | Race: concurrent create/launch can break one-running-per-type | DONE | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | per-type flock around dedupe->create->launch; php -l OK | +| RVW-002 | review-body | TaskCommand.php | 35-51 | abort/dismiss return no JSON body | BLOCKED | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | Declined: response body is unused; canonical success signal is the `tasks` nchan broadcast. Adding unused API surface conflicts with no-speculative-contract policy. Reply: https://github.com/unraid/webgui/pull/2665#issuecomment-4745856704 | +| RVW-003 | review-body | TaskCommand.php | 38-44 | Validate PID numeric before kill | DONE | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | ctype_digit()+(int)>1 guard before kill; php -l OK | +| RVW-004 | review-body | nchan/tasks | 32-37 | _ERROR_ marker substring false positives | DONE | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | match _ERROR_ as discrete \x1e record (canonical sentinel, same as routeMessage); reads tail in PHP, removing exec last-line fragility; php -l OK | +| RVW-005 | review-body | BodyInlineJS.php | 126-177 | Unescaped HTML in nchan message rendering | BLOCKED | https://github.com/unraid/webgui/pull/2665#pullrequestreview-4525730471 | Declined: renderMessage is a deliberate HTML/span protocol (addToID injects ``); blanket escaping breaks the live-log structure. Data originates from trusted server-side processes on a server-controlled channel, not untrusted client input. Reply posted. | + +## Execution Log + +1. CR-001 swal title — escaped task.title with escapeTaskHtml. DONE. +2. CR-002 onclick ids — introduced safeId=escapeTaskHtml(t.id), used everywhere. DONE. +3. CR-003 createTask — added .fail() with spinner hide + error swal. DONE. +4. CR-004 command injection — wrapped the whole `sleep .3 && $name $args$suffix` payload in escapeshellarg() (superior to literal escapeshellarg($args), which would collapse multiple space-separated args). DONE + thread reply. +5. CR-005 task_pid_alive — ctype_digit() numeric guard. DONE. +6. CR-007 queued-only recovery — added recovery pass + $changed=true so launch publishes. DONE. +7. RVW-004 _ERROR_ — discrete \x1e record match read in PHP. DONE. +8. CR-006 CSS — min-width:0. DONE. +9. RVW-001 race — per-type flock around critical section, released on every return path. DONE. +10. RVW-003 PID kill — ctype_digit()+(int)>1. DONE. +11. RVW-002 — BLOCKED (unused response surface). Reply posted. +12. RVW-005 — BLOCKED (deliberate HTML protocol, trusted source). Reply posted. + +Validation: `php -l` clean on TaskQueue.php, TaskCommand.php, nchan/tasks, BodyInlineJS.php, HeadInlineJS.php. + +## Final Checks + +- [x] Queue reviewed: no `TODO` left +- [x] Remaining `BLOCKED` items documented with reason (RVW-002, RVW-005) +- [x] Every `BLOCKED`/not-valid CodeRabbit suggestion has a PR reply with the reason it was not applied +- [x] Re-pulled CodeRabbit threads and reviews +- [x] No unhandled top-level review-body comment remains diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index e4a30d1776..c518ba7841 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -4,6 +4,7 @@ Title="Installed Plugins" Tag="icon-plugins" Tabs="true" Code="e944" +Markdown="false" --- "); diff --git a/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php b/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php index 95428cb640..45b0f0babc 100644 --- a/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php +++ b/emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php @@ -13,6 +13,18 @@  $label"; + } elseif (is_file("/tmp/plugins/pluginPending/$arg") && !$check) { return " "._('pending').""; } else { return "$check"; diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index eb40e02b7d..b5891233f3 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -211,22 +211,46 @@ function foregroundTask(id) { foregroundType = task.type; stopAllTypeChannels(); clearProgressDots(); - var showConfirm = task.type=='plugins' ? task.button==0 : task.button!=0; - var disable = task.type=='plugins' ? task.button!=0 : task.button==0; - var titleState = task.status=='done' ? "" - : task.status=='error' ? "" - : " "; - swal({title:task.title + ' - '+titleState+'',text:"

",html:true,animation:'none',showConfirmButton:showConfirm,confirmButtonText:""},function(close){ + // Drive the modal by task status, not the per-type `button` flag (which made + // docker ops hide the Close button while running and disabled the confirm + // button, surfacing SweetAlert's la-ball-fall "bouncing dots" loader): + // running -> a top-corner minimize (below) backgrounds it; no disabled + // button, so the bouncing-dots loader never shows. The spinning + // title icon is the in-progress indicator. + // finished -> a primary Dismiss button clears the task from the tray. + var finished = task.status=='done' || task.status=='error'; + // status renders as a colored "state" strip below the title (see .nchan-state) + var stateCls = task.status=='done' ? 'nchan-done' + : task.status=='error' ? 'nchan-error' : 'nchan-running'; + var stateHtml = task.status=='done' ? " " + : task.status=='error' ? " " + : " "; + swal({title:escapeTaskHtml(task.title),text:"

",html:true,animation:'none',showConfirmButton:finished,confirmButtonText:""},function(close){ + // confirm/Dismiss (or Esc): background while running, clear once finished if (foregroundTaskId===id) { foregroundTaskId=null; foregroundType=null; } stopAllTypeChannels(); clearProgressDots(); - $('.sweet-alert').hide('fast').removeClass('nchan'); var fresh = taskById(id); - if (fresh && (fresh.status=='done'||fresh.status=='error')) fireTaskCallback(fresh); + if (fresh && (fresh.status=='done'||fresh.status=='error')) { fireTaskCallback(fresh); dismissTask(id); } + nchanCloseModal(false); // swal closes via closeOnConfirm; this just cleans up trayRender(); }); - $('.sweet-alert').addClass('nchan'); - $('button.confirm').prop('disabled',disable); + $('.sweet-alert').addClass('nchan').css('pointer-events',''); + // colored state strip between the title and the log (openDone/openError recolor it) + $('.sweet-alert .nchan-state').remove(); + $('.sweet-alert > h2').after("
"+stateHtml+"
"); + // a persistent top-corner control that just closes this window, leaving the + // task in the tray: while running it reads as "minimize" (the task keeps + // running); once finished it reads as "close" (the task stays as a finished + // tile). Removal is the separate, primary Dismiss action. openDone/openError + // swap the glyph/tooltip to the finished form. + // the corner control is ALWAYS minimize (it tucks the modal away and keeps the + // task in the tray); removal is the separate Dismiss button. Keeping one icon + // avoids the confusing minus->x swap where the "x" actually just minimized. + var closeIcon = 'fa-minus'; + var closeTip = finished ? "" : ""; + $('.sweet-alert .nchan-close').remove(); + $('.sweet-alert').append(""); $('pre#swaltext').html(''); $.get(TASK_ENDPOINT,{action:'log',id:id},function(logdata){ if (foregroundTaskId!==id) return; // user moved on while loading @@ -257,7 +281,7 @@ function onTaskListUpdate() { fireTaskCallback(t); } } else if (t.status=='running' && foregroundTaskId==t.id) { - $('#pluginProgressTitle').html(" "); + $('#pluginProgressTitle').attr('class','nchan-state nchan-running').html(" "); nchanStart(nchanByType[t.type]); } } @@ -265,6 +289,9 @@ function onTaskListUpdate() { } for (var id in taskPrev) if (!taskById(id)) delete taskPrev[id]; trayRender(); + // let the current page react to task changes (e.g. Plugins page disables its + // update buttons while a plugin task is running). No-op where undefined. + if (typeof window.onTaskListChanged === 'function') { try { window.onTaskListChanged(); } catch(e) {} } } var taskChannel = new NchanSubscriber('/sub/tasks',{subscriber:'websocket', reconnectTimeout:5000}); @@ -273,38 +300,110 @@ function onTaskListUpdate() { onTaskListUpdate(); }); +// Tray expand/collapse state (mobile only). On desktop the tray is always a +// full vertical stack; on mobile it collapses to a single tappable card with +// the rest peeking behind it (the iOS/Android grouped-notification pattern). +var trayExpanded = false; +function isMobileTray() { + return !!(window.matchMedia && window.matchMedia('(max-width: 767px)').matches); +} +function expandTray() { trayExpanded = true; trayRender(); } +function collapseTray() { trayExpanded = false; trayRender(); } + // render the task tray function trayRender() { var $tray = $('#opTray'); if (!$tray.length) return; - if (!taskList.length) { $tray.hide().empty(); return; } - var rows = '', finished = 0; - for (var i=0;i=0;i--) { + var t = taskList[i], icon, actions='', safeId = escapeTaskHtml(t.id); + var top = (i==taskList.length-1) ? ' op-top' : ''; if (t.status=='done' || t.status=='error') finished++; - var show = "\">"; + var show = "\">"; if (t.status=='running') { icon = ""; - actions = show + "\">"; + actions = show + "\">"; } else if (t.status=='queued') { icon = ""; - actions = "\">"; + actions = "\">"; } else if (t.status=='done') { icon = ""; - actions = show + "\">"; + actions = show + "\">"; } else { icon = ""; - actions = show + "\">"; + actions = show + "\">"; } - rows += "
"+icon+""+escapeTaskHtml(t.title)+""+actions+"
"; + rows += "
"+icon+""+escapeTaskHtml(t.title)+""+actions+"
"; } - // header with a bulk "Clear finished" action, shown only when there is more - // than one finished task to clear (a lone one is easy to dismiss directly) - var header = ''; - if (finished > 1) { - header = "
\">
"; + // Header (only when more than one task): carries the count, a bulk "Clear + // finished" action when there is more than one finished task, and — on the + // mobile expanded stack — a chevron to collapse back to the single card. + var head = ''; + if (count > 1) { + head = "
"; + head += ""+count+" "; + head += ""; + if (finished > 1) { + head += "\"> "; + } + head += "\">"; + head += "
"; } - $tray.html(header + rows).show(); + // Collapsed badge: a count pill on the top card so a stacked group reads as + // "N operations" before it's expanded. + var badge = (count > 1) ? ""+count+"" : ""; + // State classes drive the CSS: collapsed shows just the top card with the + // others peeking; expanded shows the full vertical list with the header. + var mobile = isMobileTray(); + var collapsed = mobile && !trayExpanded && count > 1; + $tray.removeClass('op-collapsed op-expanded op-multi'); + if (count > 1) $tray.addClass('op-multi'); + $tray.addClass(collapsed ? 'op-collapsed' : 'op-expanded'); + $tray.html(head + rows + badge).show(); +} + +// When the mobile tray is collapsed into a single stacked card, a tap anywhere +// on it (other than a row action) expands the full list. Delegated so it +// survives trayRender() re-renders. +$(document).on('click', '#opTray.op-collapsed', function(e) { + if ($(e.target).closest('.op-act').length) return; + expandTray(); +}); +// Re-evaluate collapsed/expanded layout when crossing the mobile breakpoint so +// the tray doesn't get stuck in a mobile-only collapsed state on resize. +if (window.matchMedia) { + var trayMql = window.matchMedia('(max-width: 767px)'); + var onTrayBreakpoint = function(){ if (!isMobileTray()) trayExpanded = false; trayRender(); }; + if (trayMql.addEventListener) trayMql.addEventListener('change', onTrayBreakpoint); + else if (trayMql.addListener) trayMql.addListener(onTrayBreakpoint); +} + +// minimize the foreground modal: drop the live view but leave the task running +// in the backend (the tray keeps tracking it). Backgrounding, not aborting. +// Fade the modal out with its .nchan styling intact, then strip the class once +// it's gone. Removing .nchan while the modal is still visible snaps it back to +// the default swal look for a frame (the "flash"). pointer-events:none keeps the +// fading (now-invisible but still laid-out) modal from eating clicks; the guard +// avoids stripping .nchan off a modal that was reopened in the meantime. +function nchanCloseModal(doClose) { + $('.sweet-alert').css('pointer-events','none'); + if (doClose && typeof swal!=='undefined' && swal.close) swal.close(); + setTimeout(function(){ + var $sa = $('.sweet-alert'); + $sa.css('pointer-events',''); + if (!foregroundTaskId) $sa.removeClass('nchan'); + }, 350); +} + +function minimizeForegroundTask() { + if (foregroundTaskId) { foregroundTaskId=null; foregroundType=null; } + stopAllTypeChannels(); + clearProgressDots(); + nchanCloseModal(true); + trayRender(); } function cancelTask(id) { $.post(TASK_ENDPOINT,{action:'abort',id:id}); } diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 8b336954f3..37e5569d31 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -201,7 +201,10 @@ function createTask(type,cmd,title,plg,func,start,button) { if (typeof trayRender==='function') trayRender(); } foregroundTask(res.id); - },'json'); + },'json').fail(function() { + $('div.spinner.fixed').hide(); + swal({title:"",text:"",type:'error',html:true,animation:'none'}); + }); } // abortOperation(pid) retained for backward compatibility: map the pid to its @@ -245,14 +248,18 @@ function openAlert(cmd,title,func) { function openDone(data) { if (data == '_DONE_') { $('div.spinner.fixed').hide(); - $('button.confirm').text("").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; @@ -261,8 +268,10 @@ function openDone(data) { function openError(data) { if (data == '_ERROR_') { $('div.spinner.fixed').hide(); - $('button.confirm').text("").prop('disabled',false).show(); - $('#pluginProgressTitle').text(""); + $('.sweet-alert .nchan-close').attr('title',""); + $('.sweet-alert').attr('data-has-confirm-button','true'); // un-hide the footer (CSS keys off this) + $('button.confirm').text("").prop('disabled',false).show(); + $('#pluginProgressTitle').attr('class','nchan-state nchan-error').html(" "); return true; } return false; diff --git a/emhttp/plugins/dynamix/include/TaskCommand.php b/emhttp/plugins/dynamix/include/TaskCommand.php index 8ab303c5bb..378dd88d09 100644 --- a/emhttp/plugins/dynamix/include/TaskCommand.php +++ b/emhttp/plugins/dynamix/include/TaskCommand.php @@ -35,7 +35,7 @@ case 'abort': $task = task_read($id); if ($task) { - if ($task['status']==='running' && $task['pid'] > 1) { + if ($task['status']==='running' && ctype_digit((string)$task['pid']) && (int)$task['pid'] > 1) { exec('kill '.escapeshellarg($task['pid'])); foreach (glob('/tmp/plugins/pluginPending/*') ?: [] as $file) @unlink($file); $task['status'] = 'error'; diff --git a/emhttp/plugins/dynamix/include/TaskQueue.php b/emhttp/plugins/dynamix/include/TaskQueue.php index 3ee760ebc9..4cfba97ac2 100644 --- a/emhttp/plugins/dynamix/include/TaskQueue.php +++ b/emhttp/plugins/dynamix/include/TaskQueue.php @@ -120,7 +120,11 @@ function task_launch(&$task) { // plugin scripts publish to nchan only when their last argument is 'nchan' $suffix = $task['type']==='plugins' ? ' nchan' : ''; $env = 'NCHAN_TASK='.escapeshellarg($task['id']).' '; - $pid = exec($env."nohup bash -c 'sleep .3 && $name $args$suffix' 1>/dev/null 2>&1 & echo \$!"); + // escapeshellarg the whole bash -c payload so a single quote (or other shell + // metacharacter) in the resolved args cannot break out of the outer shell; + // bash still word-splits the args internally, preserving multi-arg commands. + $payload = 'sleep .3 && '.$name.' '.$args.$suffix; + $pid = exec($env.'nohup bash -c '.escapeshellarg($payload).' 1>/dev/null 2>&1 & echo $!'); $task['pid'] = $pid; $task['status'] = 'running'; $task['started'] = time(); @@ -147,11 +151,19 @@ function task_daemon_start() { // create (and possibly immediately start) a task; returns the task record function task_create($type,$cmd,$title,$plg,$func,$start,$button) { if (!in_array($type, TASK_TYPES)) return null; + // Serialize the dedupe -> create -> launch sequence per type. Without this, + // two concurrent requests (e.g. a double click) could both pass the dedupe + // and task_running_type() checks and each call task_launch(), violating the + // "at most one running task per type" invariant the whole design relies on. + $lock = fopen(task_dir()."/.$type.lock", 'c'); + if ($lock) flock($lock, LOCK_EX); // dedupe: unless unconditional (start==1), don't queue an identical pending/running op if ((int)$start !== 1) { foreach (task_list() as $t) { - if ($t['type']===$type && $t['cmd']===$cmd && in_array($t['status'],['queued','running'])) + if ($t['type']===$type && $t['cmd']===$cmd && in_array($t['status'],['queued','running'])) { + if ($lock) { flock($lock, LOCK_UN); fclose($lock); } return $t; + } } } $task = [ @@ -171,6 +183,7 @@ function task_create($type,$cmd,$title,$plg,$func,$start,$button) { ]; task_write($task); if (!task_running_type($type)) task_launch($task); + if ($lock) { flock($lock, LOCK_UN); fclose($lock); } task_daemon_start(); task_publish(); return task_read($task['id']) ?: $task; diff --git a/emhttp/plugins/dynamix/nchan/tasks b/emhttp/plugins/dynamix/nchan/tasks index e963cbd6b4..5b2cc265e3 100755 --- a/emhttp/plugins/dynamix/nchan/tasks +++ b/emhttp/plugins/dynamix/nchan/tasks @@ -24,16 +24,26 @@ $docroot = '/usr/local/emhttp'; require_once "$docroot/plugins/dynamix/include/TaskQueue.php"; -// a task's stored pid is alive while /proc/ exists +// a task's stored pid is alive while /proc/ exists; require a numeric pid so +// an empty/garbage value can't match the /proc directory itself and wedge a task function task_pid_alive($pid) { - return $pid && file_exists("/proc/$pid"); + return ctype_digit((string)$pid) && file_exists("/proc/$pid"); } -// the operation reported a failure if its captured output contains the _ERROR_ marker +// the operation reported a failure if its captured output contains the _ERROR_ +// control record. The log is RS(\x1e)-delimited and _DONE_/_ERROR_ are discrete +// records (see publish.php), so match _ERROR_ as a whole record the same way the +// live channel does (routeMessage) — a log line that merely contains the text +// must not trip a false failure. function task_log_has_error($id) { $log = task_log($id); if (!is_file($log)) return false; - return strpos((string)@exec('tail -c 65536 '.escapeshellarg($log)), '_ERROR_') !== false; + $fh = @fopen($log, 'rb'); + if (!$fh) return false; + if (filesize($log) > 65536) fseek($fh, -65536, SEEK_END); + $tail = stream_get_contents($fh); + fclose($fh); + return in_array('_ERROR_', explode("\x1e", (string)$tail), true); } // tidy up stale finished tasks, then publish the current state for any client @@ -54,6 +64,17 @@ while (true) { } } + // also launch any queued type that has nothing running. Covers a daemon + // (re)started by the page-load nchan sweep with queued-but-not-running + // records: without this it would treat those as "active" below and sleep + // forever without ever advancing the queue. + foreach (task_list() as $t) { + if ($t['status']==='queued' && !task_running_type($t['type'])) { + $freed[$t['type']] = true; + $changed = true; + } + } + // start the next queued op for every type that just freed up foreach (array_keys($freed) as $type) task_advance($type); diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 14114d7795..666b01dfde 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -1897,7 +1897,7 @@ label.checkbox input:disabled ~ .checkmark { display: flex; flex-direction: column; gap: .25rem; - z-index: 999; + z-index: 1000; } /* Lift the tray above the fixed footer so it isn't clipped behind it. Mirrors the media queries that make #footer position:fixed. */ @@ -1909,8 +1909,22 @@ label.checkbox input:disabled ~ .checkmark { } .op-tray .op-tray-head { display: flex; - justify-content: flex-end; - padding: 0 .25rem .1rem; + align-items: center; + justify-content: space-between; + gap: .5rem; + padding: 0 .35rem .15rem; +} +.op-tray .op-tray-count { + font-size: 1.05rem; + color: var(--text-color); + opacity: .6; + text-transform: uppercase; + letter-spacing: .03em; +} +.op-tray .op-tray-head-acts { + display: flex; + align-items: center; + gap: .65rem; } .op-tray .op-tray-head .op-act { font-size: 1.1rem; @@ -1920,6 +1934,10 @@ label.checkbox input:disabled ~ .checkmark { text-decoration: none; } .op-tray .op-tray-head .op-act:hover { opacity: 1; color: var(--brand-orange); } +/* The collapse chevron and the count badge are part of the mobile stacked-card + interaction; desktop always shows the full vertical list, so hide them there. */ +.op-tray .op-tray-head .op-collapse { display: none; } +.op-tray .op-stack-badge { display: none; } .op-tray .op-task { display: flex; align-items: center; @@ -1939,6 +1957,7 @@ label.checkbox input:disabled ~ .checkmark { .op-tray .op-icon { flex: 0 0 auto; } .op-tray .op-title { flex: 1 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1954,12 +1973,279 @@ label.checkbox input:disabled ~ .checkmark { text-decoration: none; } .op-tray .op-act:hover { color: var(--brand-orange); } +/* The foreground task modal: a compact, centered card (a transient progress log + shouldn't take over the screen, so it is NOT a full-height sidebar). The base + swal keeps the centering + rounded corners; here we just shrink it and lay it + out as header / scrolling log / footer. All colors are theme tokens so it + adapts to white / azure / black / gray: + surface = --dynamix-sweet-alert-icon-bg-color text = --dynamix-sweet-alert-text-color + muted = --alt-text-color card = --shade-bg-color lines = --border-color + accent = --brand-orange. Mobile goes fullscreen (below). */ +.sweet-alert.nchan { + display: flex !important; + flex-direction: column; + width: 90vw; + max-width: 60rem; + height: 75vh; /* fixed (overrides swal's @media 95vh) so the log scrolls in place and the Dismiss button never moves; scales with the viewport */ + max-height: 85vh; + margin: 0; + padding: 0; + text-align: left; + overflow: hidden; + box-sizing: border-box; + box-shadow: 0 0.5rem 2rem var(--bg-opacity-30); +} +/* SweetAlert adds 40px bottom padding when no buttons show (our running state); + the sheet manages its own padding, so cancel it. Matches swal's selector plus + .nchan to outspecify it. */ +.sweet-alert.nchan[data-has-confirm-button=false][data-has-cancel-button=false] { + padding-bottom: 0; +} +/* While running there is no Dismiss button, so hide the whole footer bar + (otherwise its border-top + padding render as an empty strip). */ +.sweet-alert.nchan[data-has-confirm-button=false] .sa-button-container { + display: none; +} +.sweet-alert.nchan > h2 { + flex: 0 0 auto; + margin: 0; + padding: 1.1rem 3.6rem 0.85rem 1.2rem; + font-size: 1.6rem; + line-height: 1.35; + font-weight: 600; + text-align: left; +} +/* status state strip below the title: a subtle theme-adaptive tint + a colored + icon, with normal (theme) text. Tints are translucent so they read over the + modal surface in every theme without the dated pale-banner look. */ +.sweet-alert.nchan .nchan-state { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + padding: 0.5rem 1.2rem; + font-size: 1.1rem; + font-weight: 500; + color: var(--text-color); + border-bottom: 1px solid var(--border-color); +} +.sweet-alert.nchan .nchan-state i { font-size: 1.05em; } +.sweet-alert.nchan .nchan-state.nchan-running { background: rgba(59,130,196,.14); } +.sweet-alert.nchan .nchan-state.nchan-running i { color: #3b82c4; } +.sweet-alert.nchan .nchan-state.nchan-done { background: rgba(58,156,71,.16); } +.sweet-alert.nchan .nchan-state.nchan-done i { color: #3a9c47; } +.sweet-alert.nchan .nchan-state.nchan-error { background: rgba(226,75,74,.14); } +.sweet-alert.nchan .nchan-state.nchan-error i { color: #e24b4a; } +.sweet-alert.nchan > p { + flex: 1 1 auto; /* fill the fixed height so the footer stays pinned to the bottom */ + min-height: 0; + margin: 0; + padding: 1rem 1.2rem; + display: flex; + flex-direction: column; + text-align: left; +} +.sweet-alert.nchan #swaltext { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow: auto; + margin: 0; + padding: 0; + text-align: left; + font-size: 1.2rem; +} +/* Slim footer with a full-width Dismiss. A single centered button looked lonely + and the default container (margin-top + centered flex + a hidden loader) ate + vertical space. */ +.sweet-alert.nchan .sa-button-container { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; + margin: 0; + padding: 0.7rem 1.2rem; + border-top: 1px solid var(--border-color); +} +.sweet-alert.nchan .sa-confirm-button-container { display: flex; justify-content: flex-end; } +.sweet-alert.nchan .la-ball-fall { display: none; } +/* !important overrides SweetAlert's inline background-color:transparent / sizing */ +.sweet-alert.nchan button.confirm { + display: inline-block; + width: auto; + margin: 0; + padding: 0.5rem 1.4rem; + font-size: 1.25rem; + color: var(--white) !important; + background: var(--brand-orange) !important; + border: 0 !important; + border-radius: 0.4rem; + box-shadow: none !important; +} +.sweet-alert.nchan button.confirm:hover { background: var(--orange-800) !important; } +/* top-corner minimize (running) / close (finished) control — theme-token colors + so it stays visible on the modal surface in every theme */ +.sweet-alert .nchan-close { + position: absolute; + top: 0.85rem; + right: 0.9rem; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 1.3rem; + line-height: 1; + color: var(--dynamix-sweet-alert-text-color); + background: transparent; + border: 1px solid var(--border-color); + cursor: pointer; + text-decoration: none; + transition: background-color .15s, border-color .15s, color .15s; + z-index: 2; +} +.sweet-alert .nchan-close:hover { + color: var(--white); + background: var(--brand-orange); + border-color: var(--brand-orange); +} +/* Mobile: fullscreen (a centered card is cramped on phones). Overrides the base + swal centering to fill the viewport. */ +@media (max-width: 767px) { + .sweet-alert.nchan { + left: 0; + top: 0; + right: 0; + bottom: 0; + transform: none; + -webkit-transform: none; + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + border: 0; + border-radius: 0; + } + .sweet-alert.nchan .nchan-close { width: 2.9rem; height: 2.9rem; font-size: 1.6rem; } +} .back_to_top { right: 40px; } .move_to_end { right:12px; } +/* The scroll-to-top/bottom buttons share the bottom-right corner with the task + tray. When the tray has tasks (it is an earlier sibling of the buttons, see + MiscElementsBottom), move the buttons clear of it: to the bottom-left on + desktop. */ +.op-tray:not(:empty) ~ .back_to_top { right: auto; left: 40px; } +.op-tray:not(:empty) ~ .move_to_end { right: auto; left: 12px; } +/* Mobile: the tray is a grouped-notification stack (iOS/Android pattern). When + there are multiple tasks it collapses to the newest card with the rest peeking + behind it; a tap expands the full vertical list so all of them can be viewed + and cleared at once. The scroll buttons lift above it so they never overlap. */ +@media (max-width: 767px) { + .op-tray { + left: 1rem; + right: 1rem; + width: auto; + max-width: none; + gap: .5rem; + } + /* larger tap targets on touch (applies in both collapsed and expanded) */ + .op-tray .op-task { padding: .85rem 1rem; min-height: 3.4rem; font-size: 1.45rem; gap: .75rem; } + .op-tray .op-actions { gap: .25rem; } + .op-tray .op-act { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3rem; + min-height: 3rem; + font-size: 1.6rem; + } + + /* ---- Collapsed: a single tappable card with stacked edges peeking under ---- */ + .op-tray.op-collapsed { + display: block; + position: fixed; + max-height: none; + overflow: visible; + /* room under the front card for the two peeking stack edges + the badge */ + padding-bottom: .9rem; + cursor: pointer; + } + .op-tray.op-collapsed .op-tray-head { display: none; } + .op-tray.op-collapsed .op-task { display: none; } + .op-tray.op-collapsed .op-task.op-top { + display: flex; + position: relative; + z-index: 2; + margin: 0; + } + /* collapsed = whole card means "expand"; its row actions are reachable once + expanded, so suppress them here to keep the tap target unambiguous */ + .op-tray.op-collapsed .op-task.op-top .op-actions { display: none; } + /* two faux card edges fanned out below the front card to signal a stack */ + .op-tray.op-collapsed.op-multi .op-task.op-top::before, + .op-tray.op-collapsed.op-multi .op-task.op-top::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + height: 1rem; + border: 1px solid var(--border-color); + border-top: 0; + border-bottom-left-radius: .5rem; + border-bottom-right-radius: .5rem; + background-color: var(--background-color); + z-index: -1; + } + .op-tray.op-collapsed.op-multi .op-task.op-top::after { + bottom: -.45rem; + width: 92%; + opacity: .8; + } + .op-tray.op-collapsed.op-multi .op-task.op-top::before { + bottom: -.9rem; + width: 84%; + opacity: .55; + } + /* count pill on the front card */ + .op-tray.op-collapsed .op-stack-badge { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + top: -.7rem; + right: -.4rem; + z-index: 3; + min-width: 1.6rem; + height: 1.6rem; + padding: 0 .45rem; + border-radius: .8rem; + background-color: var(--brand-orange); + color: #fff; + font-size: 1.05rem; + font-weight: 700; + box-shadow: 0 1px 3px rgba(0,0,0,.35); + cursor: pointer; + } + + /* ---- Expanded: full vertical list, panned up from the bottom ---- */ + .op-tray.op-expanded { + display: flex; + flex-direction: column; + max-height: 70vh; + overflow-y: auto; + } + .op-tray.op-expanded .op-tray-head .op-collapse { display: inline-flex; } + + /* lift the scroll buttons clear of the taller mobile tray (front card + + peeking stack edges + count badge sit ~5.5rem up from the bottom) */ + .op-tray:not(:empty) ~ .back_to_top { left: auto; right: 40px; bottom: 7.5rem; } + .op-tray:not(:empty) ~ .move_to_end { left: auto; right: 12px; bottom: 7.5rem; } +} span.big.blue-text { cursor: pointer; }