From 8da3c14af610498965c0eec3fafada7676184d5e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 19 Jun 2026 16:27:09 -0400 Subject: [PATCH 01/10] feat(notify): persistent/keyed notifications; route reboot notice to the bell Adds the producer side of persistent ("sticky until resolved") notifications so the API notification bell can replace the legacy floating yellow banner. - webGui/scripts/notify: - `add` gains `-k ` (stable key; keyed notices use the key as the filename so re-raising is idempotent) and `-p` (persistent: written as persistent=true, not user-archivable, and not copied to the archive folder). - new `notify clear -k ` resolves a keyed notification. - PluginAPI.php: the reboot-required notice now also raises a persistent, keyed (`reboot-required`) ALERT notification, and clears it once no reboot reasons remain. It also clears naturally on reboot (RAM-backed /tmp is wiped). Pairs with unraid/api#2033 (persistent + key fields, clearNotificationByKey). Part of OS-471. Follow-up: migrate the remaining banner call sites (boot-device-corrupt -> persistent; transient -> toaster) and remove the .upgrade_notice element. Co-Authored-By: Claude Opus 4.8 --- .../scripts/PluginAPI.php | 6 +++ emhttp/plugins/dynamix/scripts/notify | 43 ++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php b/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php index 66b457448f..d3e16bdbf4 100644 --- a/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php +++ b/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php @@ -82,6 +82,10 @@ function download_url($url, $path = "") { $existing = (array)@file("/tmp/reboot_notifications",FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $existing[] = $message; file_put_contents("/tmp/reboot_notifications",implode("\n",array_unique($existing))); + // Surface "reboot required" as a persistent notification (sticky in the bell, + // keyed so repeated notices update rather than stack). It clears on reboot + // (RAM-backed /tmp is wiped) or via removeRebootNotice below. + exec("/usr/local/emhttp/webGui/scripts/notify add -p -k reboot-required -i alert -e ".escapeshellarg("Reboot required")." -s ".escapeshellarg("Reboot required")." -d ".escapeshellarg($message)." &>/dev/null"); break; case 'removeRebootNotice': @@ -89,6 +93,8 @@ function download_url($url, $path = "") { $existing = file_get_contents("/tmp/reboot_notifications"); $newReboots = str_replace($message,"",$existing); file_put_contents("/tmp/reboot_notifications",$newReboots); + // Once no reboot reasons remain, resolve the persistent notification. + if (trim($newReboots)==="") exec("/usr/local/emhttp/webGui/scripts/notify clear -k reboot-required &>/dev/null"); break; } ?> diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify index 1c4db802b5..6198637435 100755 --- a/emhttp/plugins/dynamix/scripts/notify +++ b/emhttp/plugins/dynamix/scripts/notify @@ -19,7 +19,7 @@ require_once "$docroot/webGui/include/Encryption.php"; function usage() { echo << $value) { switch ($option) { case 'e': @@ -241,6 +248,12 @@ case 'add': case 'b': $noBrowser = true; break; + case 'k': + $key = $value; + break; + case 'p': + $persistent = true; + break; case 'l': $nginx = (array)@parse_ini_file('/var/local/emhttp/nginx.ini'); $link = $value; @@ -249,8 +262,11 @@ case 'add': } } - $unread = "{$unread}/".safe_filename("{$event}-{$ticket}.notify"); - $archive = "{$archive}/".safe_filename("{$event}-{$ticket}.notify"); + // Condition-style notifications use a stable key for the filename so raising the + // same condition again overwrites it (idempotent) instead of stacking duplicates. + $idBase = $key !== '' ? $key : "{$event}-{$ticket}"; + $unread = "{$unread}/".safe_filename("{$idBase}.notify"); + $archive = "{$archive}/".safe_filename("{$idBase}.notify"); if (file_exists($archive)) break; $entity = $overrule===false ? $notify[$importance] : $overrule; $cleanSubject = clean_subject($subject); @@ -262,7 +278,9 @@ case 'add': 'importance' => $importance, ]; if ($message) $archiveData['message'] = str_replace('\n','
',$message); - if (!$mailtest) file_put_contents($archive, build_ini_string($archiveData)); + // Persistent (condition-style) notifications are never archived - they clear when + // their condition resolves - so skip writing the archive copy for them. + if (!$mailtest && !$persistent) file_put_contents($archive, build_ini_string($archiveData)); if (($entity & 1)==1 && !$mailtest && !$noBrowser) { $unreadData = [ 'timestamp' => $timestamp, @@ -272,6 +290,8 @@ case 'add': 'importance' => $importance, 'link' => $link, ]; + if ($key !== '') $unreadData['key'] = $key; + if ($persistent) $unreadData['persistent'] = 'true'; file_put_contents($unread, build_ini_string($unreadData)); } if (($entity & 2)==2 || $mailtest) generate_email($event, $cleanSubject, str_replace('
','. ',$description), $importance, $message, $recipients, $fqdnlink); @@ -308,6 +328,19 @@ case 'archive': $file = $argv[2]; if (strpos(realpath("$unread/$file"),$unread.'/')===0) @unlink("$unread/$file"); break; + +case 'clear': + // Resolve a condition-style notification by its stable key: + // notify clear -k (or) notify clear + $opt = getopt("k:"); + $key = $opt['k'] ?? ($argv[2] ?? ''); + if ($key === '' || $key === false) exit(usage()); + $name = safe_filename("{$key}.notify"); + foreach ([$unread, $archive] as $dir) { + $target = "$dir/$name"; + if (strpos(realpath($target) ?: '', $dir.'/')===0) @unlink($target); + } + break; } exit(0); From 4465788a4db9d76ff9fb53fd71e74776f32e70f2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 19 Jun 2026 17:06:00 -0400 Subject: [PATCH 02/10] fix(notify): correct option parsing for persistent notification calls Two bugs found while deploying to a live server: - PluginAPI.php called `notify add -p -k ...`; PHP getopt() stops at the first non-option arg, so the literal `add` made it parse zero options. The script dispatches to 'add' whenever the first arg is an option, so call it options-first (no `add` literal). - `notify clear` parsed its key with getopt(), which likewise stops at the 'clear' sub-command. Parse argv directly to support `clear ` and `clear -k `. Verified on hardware: `notify -p -k reboot-required ...` writes a persistent keyed notification and `notify clear -k reboot-required` removes it (safe_filename maps the key symmetrically for add + clear). Co-Authored-By: Claude Opus 4.8 --- .../dynamix.plugin.manager/scripts/PluginAPI.php | 2 +- emhttp/plugins/dynamix/scripts/notify | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php b/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php index d3e16bdbf4..8ebf5dbd78 100644 --- a/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php +++ b/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php @@ -85,7 +85,7 @@ function download_url($url, $path = "") { // Surface "reboot required" as a persistent notification (sticky in the bell, // keyed so repeated notices update rather than stack). It clears on reboot // (RAM-backed /tmp is wiped) or via removeRebootNotice below. - exec("/usr/local/emhttp/webGui/scripts/notify add -p -k reboot-required -i alert -e ".escapeshellarg("Reboot required")." -s ".escapeshellarg("Reboot required")." -d ".escapeshellarg($message)." &>/dev/null"); + exec("/usr/local/emhttp/webGui/scripts/notify -p -k reboot-required -i alert -e ".escapeshellarg("Reboot required")." -s ".escapeshellarg("Reboot required")." -d ".escapeshellarg($message)." &>/dev/null"); break; case 'removeRebootNotice': diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify index 6198637435..d3254a98b0 100755 --- a/emhttp/plugins/dynamix/scripts/notify +++ b/emhttp/plugins/dynamix/scripts/notify @@ -331,10 +331,14 @@ case 'archive': case 'clear': // Resolve a condition-style notification by its stable key: - // notify clear -k (or) notify clear - $opt = getopt("k:"); - $key = $opt['k'] ?? ($argv[2] ?? ''); - if ($key === '' || $key === false) exit(usage()); + // notify clear (or) notify clear -k + // Parse argv directly: getopt() stops at the leading 'clear' sub-command. + $key = ''; + if (isset($argv[2])) { + if ($argv[2] === '-k') $key = $argv[3] ?? ''; + elseif ($argv[2][0] !== '-') $key = $argv[2]; + } + if ($key === '') exit(usage()); $name = safe_filename("{$key}.notify"); foreach ([$unread, $archive] as $dir) { $target = "$dir/$name"; From bde48a6fdfab6ebb7f01faa59b802c912e520cf2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 19 Jun 2026 18:01:15 -0400 Subject: [PATCH 03/10] fix(notify): re-raise keyed notifications instead of bailing on archived copy The legacy "if archived copy exists, skip" dedup blocked re-raising a keyed condition after the user dismissed (archived) it - so a still-true condition could never re-pin. Keyed notifications now clear any stale archived copy and proceed, so re-raising re-pins and keeps the archive clean. Non-keyed notifications keep the legacy dedup behavior. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/scripts/notify | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify index d3254a98b0..9de9e44573 100755 --- a/emhttp/plugins/dynamix/scripts/notify +++ b/emhttp/plugins/dynamix/scripts/notify @@ -267,7 +267,12 @@ case 'add': $idBase = $key !== '' ? $key : "{$event}-{$ticket}"; $unread = "{$unread}/".safe_filename("{$idBase}.notify"); $archive = "{$archive}/".safe_filename("{$idBase}.notify"); - if (file_exists($archive)) break; + // Legacy dedup: don't recreate a notification the user already archived. + // Keyed (condition-style) notifications are exempt: re-raising a condition should + // re-pin it, so clear any stale archived copy and proceed instead of bailing. + if (file_exists($archive)) { + if ($key !== '') @unlink($archive); else break; + } $entity = $overrule===false ? $notify[$importance] : $overrule; $cleanSubject = clean_subject($subject); $archiveData = [ From 369816191076113b28744caac3ff2b2827926df6 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 19 Jun 2026 20:27:08 -0400 Subject: [PATCH 04/10] feat(banners): route persistent banners to the bell, transient to toasts Retire the fixed yellow .upgrade_notice bar by routing addBannerWarning by intent: - Persistent conditions (noDismiss, not a transient "forced" in-progress banner) -> a keyed notification in the bell via Notify.php (idempotent re-raise, header-reserved, survives reloads, clears when resolved). Boot-device-corrupt now routes here too. - Everything else -> a transient toast (globalThis.toast), with any banner link preserved as a toast action button. Notify.php add gains -p/-k/-l passthrough plus a clear (clear-by-key) command. Reboot notices are no longer double-shown: they rely solely on the PluginAPI "reboot-required" bell notification (dropped the client banner + on-load re-display). Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/HeadInlineJS.php | 115 +++++++++++++----- emhttp/plugins/dynamix/include/Notify.php | 9 ++ 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index f8b697a55c..25f45bc4e5 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -379,35 +379,95 @@ function escapeQuotes(form) { var osUpgradeWarning = false; var forcedBanner = false; +// The legacy fixed yellow ".upgrade_notice" bar is retired. addBannerWarning now +// routes by intent: +// - Persistent conditions (noDismiss, not a transient "forced" in-progress +// banner) -> the notification bell, as a keyed (idempotent, deduped, +// header-reserved) notification that survives reloads and clears when +// resolved. Re-raising the same condition just updates the one entry. +// - Everything else -> a transient toast that auto-dismisses. +// removeBannerWarning(id) clears the bell notification or dismisses the toast. +var bannerToastSeq = 0; +var bannerRegistry = {}; // id -> { persistent: bool, key?: string } + +function bannerStableKey(text) { + var h = 0; + for (var i=0;i>>0).toString(36); +} + +// Split a banner's HTML blob into plain text + a single action link. +function parseBanner(html) { + var tmp = document.createElement('div'); + tmp.innerHTML = html; + var a = tmp.querySelector('a'); + var link = null; + if (a) { + link = { href: a.getAttribute('href'), onclick: a.getAttribute('onclick'), label: (a.textContent||'').trim() }; + a.remove(); + } + return { text: (tmp.textContent||'').replace(/\s+/g,' ').trim(), link: link }; +} + function addBannerWarning(text, warning=true, noDismiss=false, forced=false) { - var cookieText = text.replace(/[^a-z0-9]/gi,''); - if ($.cookie(cookieText) == "true") return false; - if (warning) text = " "+text; - if (bannerWarnings.indexOf(text) < 0) { - if (forced) { - var arrayEntry = 0; bannerWarnings = []; clearTimeout(timers.bannerWarning); timers.bannerWarning = null; forcedBanner = true; - } else { - var arrayEntry = bannerWarnings.push("placeholder") - 1; - } - if (!noDismiss) text += ""; - bannerWarnings[arrayEntry] = text; - } else { - return bannerWarnings.indexOf(text); + var id = "banner-" + (++bannerToastSeq); + var parsed = parseBanner(text); + var importance = (warning || noDismiss) ? "warning" : "info"; + + if (noDismiss && !forced) { + // Persistent condition -> notification bell (keyed for idempotent re-raise). + var key = bannerStableKey(parsed.text); + bannerRegistry[id] = { persistent: true, key: key }; + $.post('/webGui/include/Notify.php', { + cmd: 'add', + i: importance === "warning" ? "warning" : "normal", + e: parsed.text, + s: '', + d: '.', + l: (parsed.link && parsed.link.href) ? parsed.link.href : '', + p: '1', + k: key + }); + return id; } - if (timers.bannerWarning==null) showBannerWarnings(); - return arrayEntry; + + // Transient (or forced in-progress) -> toast. + bannerRegistry[id] = { persistent: false }; + showBannerToast(parsed, importance, !!noDismiss, id, 0); + return id; +} + +function showBannerToast(parsed, importance, persist, id, attempt) { + // globalThis.toast is registered when the toaster web component mounts; a + // banner may fire before then, so retry briefly. + if (!window.toast || !window.toast.warning) { + if (attempt < 40) setTimeout(function(){ showBannerToast(parsed, importance, persist, id, attempt+1); }, 150); + return; + } + var opts = { id: id, duration: persist ? Infinity : 8000, closeButton: true }; + if (parsed.link) { + var l = parsed.link; + opts.action = { label: l.label || 'Open', onClick: function() { + if (l.href) window.location.href = l.href; + else if (l.onclick) { try { (new Function(l.onclick)).call(window); } catch(e) {} } + }}; + } + var fn = window.toast[importance] || window.toast; + fn(parsed.text, opts); } function dismissBannerWarning(entry,cookieText) { - $.cookie(cookieText,"true",{expires:30}); // reset after 1 month removeBannerWarning(entry); } function removeBannerWarning(entry) { - if (forcedBanner) return; - bannerWarnings[entry] = false; - clearTimeout(timers.bannerWarning); - showBannerWarnings(); + var info = bannerRegistry[entry]; + if (info && info.persistent && info.key) { + $.post('/webGui/include/Notify.php', { cmd: 'clear', k: info.key }); + } else if (window.toast && window.toast.dismiss) { + window.toast.dismiss(entry); + } + delete bannerRegistry[entry]; } function bannerFilterArray(array) { @@ -432,14 +492,12 @@ function showBannerWarnings() { } function addRebootNotice(message="") { - addBannerWarning(" "+message,false,true); + // PluginAPI raises a persistent, keyed "reboot-required" bell notification + // (and persists it server-side), so no separate client banner is needed. $.post("/plugins/dynamix.plugin.manager/scripts/PluginAPI.php",{action:'addRebootNotice',message:message}); } function removeRebootNotice(message="") { - var bannerIndex = bannerWarnings.indexOf(" "+message); - if (bannerIndex < 0) return; - removeBannerWarning(bannerIndex); $.post("/plugins/dynamix.plugin.manager/scripts/PluginAPI.php",{action:'removeRebootNotice',message:message}); } @@ -493,7 +551,7 @@ function digits(number) { function flashReport() { $.post('/webGui/include/Report.php',{cmd:'config'},function(check){ - if (check>0) addBannerWarning(" "); + if (check>0) addBannerWarning(" ",true,true); }); } @@ -522,11 +580,8 @@ function flashReport() { updateTime(); Shadowbox.setup('a.sb-enable', {modal:true}); -// add any pre-existing reboot notices - $.post('/webGui/include/Report.php',{cmd:'notice'},function(notices){ - notices = notices.split('\n'); - for (var i=0,notice; notice=notices[i]; i++) addBannerWarning(" "+notice,false,true); - }); +// Pre-existing reboot notices now live in the notification bell (raised by +// PluginAPI when the reboot reason was added), so no client-side re-display. // check for boot device offline / corrupted (delayed). timers.flashReport = setTimeout(flashReport,6000); }); diff --git a/emhttp/plugins/dynamix/include/Notify.php b/emhttp/plugins/dynamix/include/Notify.php index e692b41b03..626cbf27d6 100644 --- a/emhttp/plugins/dynamix/include/Notify.php +++ b/emhttp/plugins/dynamix/include/Notify.php @@ -34,16 +34,25 @@ case 'd': case 'i': case 'm': + case 'k': + case 'l': $notify .= " -{$option} ".escapeshellarg($value); break; case 'x': case 't': + case 'p': $notify .= " -{$option}"; break; } } shell_exec("$notify add"); break; + +case 'clear': + // Resolve a keyed (condition-style / persistent) notification. + $key = $_POST['k']??''; + if ($key !== '') shell_exec("$notify clear -k ".escapeshellarg($key)); + break; case 'get': echo shell_exec("$notify get"); break; From a8e7f6ae96f5633477a5695399179f913db4d574 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 19 Jun 2026 20:30:37 -0400 Subject: [PATCH 05/10] pr-plugin: clear keyed PR-test notification on uninstall The Banner page raises a persistent, keyed bell notification (key=webgui-pr-PR_PLACEHOLDER) instead of a banner. Clear it in the plugin removal script so uninstalling the PR test plugin also removes the notification. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/generate-pr-plugin.sh | 54 +++++++++++++-------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/.github/scripts/generate-pr-plugin.sh b/.github/scripts/generate-pr-plugin.sh index b79cc77886..29360e4972 100755 --- a/.github/scripts/generate-pr-plugin.sh +++ b/.github/scripts/generate-pr-plugin.sh @@ -252,40 +252,33 @@ Link='nav-user' --- ]]> @@ -352,6 +345,9 @@ echo "Cleaning up plugin files..." rm -rf "/usr/local/emhttp/plugins/webgui-pr-PR_PLACEHOLDER" rm -rf "$PLUGIN_DIR" +# Clear the persistent "PR test build installed" notification raised by the Banner page. +/usr/local/emhttp/webGui/scripts/notify clear -k "webgui-pr-PR_PLACEHOLDER" 2>/dev/null || true + echo "" echo "✅ Plugin removed successfully" echo "⚠️ A reboot may be required to fully clear all changes" From feeb2a0b0bb46edcbcfb620373f0d640c386e10a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 20 Jun 2026 00:14:15 -0400 Subject: [PATCH 06/10] notify: reconcile JS-sourced banner notifications via generation sweep Legacy banners (CA's "Action Centre Enabled", boot checks, ...) routed to the bell have no explicit clear: they re-render on every page load while active and simply stop when resolved, so a persistent bell notification would stick forever. Stamp each banner -> bell notification with a per-page-load generation, and sweep any 'banner-' keyed notification not re-raised in the current generation once the page settles. Resolved banners now clear on the next navigation. Non-banner keys (PR plugins, reboot-required) own their lifecycle and are left untouched. - notify: add -g on add; add `banner-sweep ` subcommand - Notify.php: forward -g on add; add cmd=banner-sweep - HeadInlineJS: stamp bell POST with a per-load bannerGen; sweep 3s after load Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/HeadInlineJS.php | 18 +++++++++- emhttp/plugins/dynamix/include/Notify.php | 7 ++++ emhttp/plugins/dynamix/scripts/notify | 33 ++++++++++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 25f45bc4e5..15cc90e2b1 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -389,6 +389,12 @@ function escapeQuotes(form) { // removeBannerWarning(id) clears the bell notification or dismisses the toast. var bannerToastSeq = 0; var bannerRegistry = {}; // id -> { persistent: bool, key?: string } +// Per-page-load generation stamp. Legacy banners (CA, boot checks, ...) have no +// explicit clear: they re-render every load while active and simply stop when +// resolved. Each banner -> bell notification is stamped with this generation; +// after the page settles we sweep any 'banner-' notification not re-raised this +// load, so resolved banners clear on the next navigation. +var bannerGen = String(Date.now()); function bannerStableKey(text) { var h = 0; @@ -426,7 +432,8 @@ function addBannerWarning(text, warning=true, noDismiss=false, forced=false) { d: '.', l: (parsed.link && parsed.link.href) ? parsed.link.href : '', p: '1', - k: key + k: key, + g: bannerGen }); return id; } @@ -460,6 +467,15 @@ function dismissBannerWarning(entry,cookieText) { removeBannerWarning(entry); } +// Reconcile banner -> bell notifications once the page has settled: clear any +// 'banner-' notification not stamped with this load's generation (i.e. whose +// producer stopped rendering it). Deferred past load so banners raised via ajax +// (e.g. the boot-corrupt check) are re-stamped before the sweep runs. +function sweepStaleBanners() { + $.post('/webGui/include/Notify.php', { cmd: 'banner-sweep', g: bannerGen }); +} +$(window).on('load', function(){ setTimeout(sweepStaleBanners, 3000); }); + function removeBannerWarning(entry) { var info = bannerRegistry[entry]; if (info && info.persistent && info.key) { diff --git a/emhttp/plugins/dynamix/include/Notify.php b/emhttp/plugins/dynamix/include/Notify.php index 626cbf27d6..36a04a9f30 100644 --- a/emhttp/plugins/dynamix/include/Notify.php +++ b/emhttp/plugins/dynamix/include/Notify.php @@ -36,6 +36,7 @@ case 'm': case 'k': case 'l': + case 'g': $notify .= " -{$option} ".escapeshellarg($value); break; case 'x': @@ -53,6 +54,12 @@ $key = $_POST['k']??''; if ($key !== '') shell_exec("$notify clear -k ".escapeshellarg($key)); break; +case 'banner-sweep': + // Reconcile JS-sourced banner notifications: clear any 'banner-' keyed + // notification not re-raised in the current page-load generation. + $gen = $_POST['g']??''; + if ($gen !== '') shell_exec("$notify banner-sweep ".escapeshellarg($gen)); + break; case 'get': echo shell_exec("$notify get"); break; diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify index 9de9e44573..e5d8219e0d 100755 --- a/emhttp/plugins/dynamix/scripts/notify +++ b/emhttp/plugins/dynamix/scripts/notify @@ -216,8 +216,9 @@ case 'add': $noBrowser = false; $key = ''; $persistent = false; + $gen = ''; - $options = getopt("l:e:s:d:i:m:r:k:xtbp"); + $options = getopt("l:e:s:d:i:m:r:k:g:xtbp"); foreach ($options as $option => $value) { switch ($option) { case 'e': @@ -254,6 +255,9 @@ case 'add': case 'p': $persistent = true; break; + case 'g': + $gen = $value; + break; case 'l': $nginx = (array)@parse_ini_file('/var/local/emhttp/nginx.ini'); $link = $value; @@ -297,6 +301,9 @@ case 'add': ]; if ($key !== '') $unreadData['key'] = $key; if ($persistent) $unreadData['persistent'] = 'true'; + // Generation stamp for JS-sourced banner notifications: lets 'banner-sweep' + // reconcile (clear) banners that were not re-raised on the latest page load. + if ($gen !== '') $unreadData['gen'] = $gen; file_put_contents($unread, build_ini_string($unreadData)); } if (($entity & 2)==2 || $mailtest) generate_email($event, $cleanSubject, str_replace('
','. ',$description), $importance, $message, $recipients, $fqdnlink); @@ -350,6 +357,30 @@ case 'clear': if (strpos(realpath($target) ?: '', $dir.'/')===0) @unlink($target); } break; + +case 'banner-sweep': + // Reconcile JS-sourced banner notifications (keys prefixed 'banner-'): + // notify banner-sweep + // Legacy banners (CA, boot checks, ...) have no explicit clear - they signal + // "still active" by re-rendering on every page load. addBannerWarning stamps + // each re-raise with the current page-load generation; this sweep removes any + // banner notification NOT stamped with the current generation, i.e. one whose + // producer stopped rendering it. Non-banner keys (PR plugins, reboot, ...) own + // their lifecycle and are left untouched. + $currentGen = $argv[2] ?? ''; + if ($currentGen === '') exit(usage()); + foreach (glob("$unread/*.notify", GLOB_NOSORT) ?: [] as $file) { + $k = $g = ''; + foreach (file($file, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + [$field,$val] = array_pad(explode('=', $line, 2), 2, ''); + $field = trim($field); + if ($field === 'key') $k = ini_decode_value($val); + elseif ($field === 'gen') $g = ini_decode_value($val); + } + if (strpos($k, 'banner-') !== 0) continue; + if ($g !== $currentGen) @unlink($file); + } + break; } exit(0); From 542d0addbf9e12a939d5bb8ecbed15f5dc9da0c1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 20 Jun 2026 08:55:12 -0400 Subject: [PATCH 07/10] notify: stamp banners with a generation; let the API reconcile them Move banner reconciliation to the backend (authoritative, triggered when the notification drawer loads). The page's role is reduced to: - stamp each banner -> bell notification with a per-page-load generation (notify add -g ), so the API can tell which banners were re-raised this load - expose window.bannerGen for the drawer to hand to the API Drop the page-side banner-sweep (notify subcommand, Notify.php command, and the HeadInlineJS post-load timer) in favor of the API's reconcileBannerNotifications mutation (unraid/api). The -g stamp on `notify add` is retained. Co-Authored-By: Claude Opus 4.8 --- .../DefaultPageLayout/HeadInlineJS.php | 18 ++++---------- emhttp/plugins/dynamix/include/Notify.php | 6 ----- emhttp/plugins/dynamix/scripts/notify | 24 ------------------- 3 files changed, 5 insertions(+), 43 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 15cc90e2b1..218adf9251 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -391,10 +391,11 @@ function escapeQuotes(form) { var bannerRegistry = {}; // id -> { persistent: bool, key?: string } // Per-page-load generation stamp. Legacy banners (CA, boot checks, ...) have no // explicit clear: they re-render every load while active and simply stop when -// resolved. Each banner -> bell notification is stamped with this generation; -// after the page settles we sweep any 'banner-' notification not re-raised this -// load, so resolved banners clear on the next navigation. -var bannerGen = String(Date.now()); +// resolved. Each banner -> bell notification is stamped with this generation. +// Exposed on window so the notification drawer can hand it to the API, which +// authoritatively clears any 'banner-' notification not re-raised this load +// (see reconcileBannerNotifications). +var bannerGen = window.bannerGen = String(Date.now()); function bannerStableKey(text) { var h = 0; @@ -467,15 +468,6 @@ function dismissBannerWarning(entry,cookieText) { removeBannerWarning(entry); } -// Reconcile banner -> bell notifications once the page has settled: clear any -// 'banner-' notification not stamped with this load's generation (i.e. whose -// producer stopped rendering it). Deferred past load so banners raised via ajax -// (e.g. the boot-corrupt check) are re-stamped before the sweep runs. -function sweepStaleBanners() { - $.post('/webGui/include/Notify.php', { cmd: 'banner-sweep', g: bannerGen }); -} -$(window).on('load', function(){ setTimeout(sweepStaleBanners, 3000); }); - function removeBannerWarning(entry) { var info = bannerRegistry[entry]; if (info && info.persistent && info.key) { diff --git a/emhttp/plugins/dynamix/include/Notify.php b/emhttp/plugins/dynamix/include/Notify.php index 36a04a9f30..272ee929d3 100644 --- a/emhttp/plugins/dynamix/include/Notify.php +++ b/emhttp/plugins/dynamix/include/Notify.php @@ -54,12 +54,6 @@ $key = $_POST['k']??''; if ($key !== '') shell_exec("$notify clear -k ".escapeshellarg($key)); break; -case 'banner-sweep': - // Reconcile JS-sourced banner notifications: clear any 'banner-' keyed - // notification not re-raised in the current page-load generation. - $gen = $_POST['g']??''; - if ($gen !== '') shell_exec("$notify banner-sweep ".escapeshellarg($gen)); - break; case 'get': echo shell_exec("$notify get"); break; diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify index e5d8219e0d..c517367fd2 100755 --- a/emhttp/plugins/dynamix/scripts/notify +++ b/emhttp/plugins/dynamix/scripts/notify @@ -357,30 +357,6 @@ case 'clear': if (strpos(realpath($target) ?: '', $dir.'/')===0) @unlink($target); } break; - -case 'banner-sweep': - // Reconcile JS-sourced banner notifications (keys prefixed 'banner-'): - // notify banner-sweep - // Legacy banners (CA, boot checks, ...) have no explicit clear - they signal - // "still active" by re-rendering on every page load. addBannerWarning stamps - // each re-raise with the current page-load generation; this sweep removes any - // banner notification NOT stamped with the current generation, i.e. one whose - // producer stopped rendering it. Non-banner keys (PR plugins, reboot, ...) own - // their lifecycle and are left untouched. - $currentGen = $argv[2] ?? ''; - if ($currentGen === '') exit(usage()); - foreach (glob("$unread/*.notify", GLOB_NOSORT) ?: [] as $file) { - $k = $g = ''; - foreach (file($file, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) ?: [] as $line) { - [$field,$val] = array_pad(explode('=', $line, 2), 2, ''); - $field = trim($field); - if ($field === 'key') $k = ini_decode_value($val); - elseif ($field === 'gen') $g = ini_decode_value($val); - } - if (strpos($k, 'banner-') !== 0) continue; - if ($g !== $currentGen) @unlink($file); - } - break; } exit(0); From ecacf04e7a75c30ca6a9a410c1cb14f80a34b464 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 20 Jun 2026 09:01:33 -0400 Subject: [PATCH 08/10] banner: drop boilerplate description from banner -> bell notifications The generic "This stays in your notifications until the condition is resolved" description was filler - the "Active" badge already conveys persistence - and rendered as a redundant second line. Send an empty description so the bell card shows only the banner message (paired with the web fix that hides empty lines). Co-Authored-By: Claude Opus 4.8 --- .../plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php index 218adf9251..b366980f07 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php @@ -430,7 +430,7 @@ function addBannerWarning(text, warning=true, noDismiss=false, forced=false) { i: importance === "warning" ? "warning" : "normal", e: parsed.text, s: '', - d: '.', + d: '', // no description: the "Active" badge already conveys persistence l: (parsed.link && parsed.link.href) ? parsed.link.href : '', p: '1', k: key, From a11896e982737579e328728ec851baed6b489e42 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 22 Jun 2026 16:32:09 -0400 Subject: [PATCH 09/10] notify: store persistent notifications in a dedicated active/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistent (-p) notifications now write to $path/active/ instead of unread/, so they can be retrieved cheaply and are never swept by bulk archive/delete. To keep the legacy nchan poller working, `notify get` unions active/ with unread/. `clear -k` and `init` sweep active/ too, and a raise clears any stale twin left in the other store. No migration needed — persistent notifications regenerate on page load. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/scripts/notify | 40 +++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify index c517367fd2..dbc88b3eba 100755 --- a/emhttp/plugins/dynamix/scripts/notify +++ b/emhttp/plugins/dynamix/scripts/notify @@ -148,6 +148,11 @@ extract(parse_plugin_cfg("dynamix",true)); $path = _var($notify,'path','/tmp/notifications'); $unread = "$path/unread"; $archive = "$path/archive"; +// Persistent (condition-style) notifications live in their own directory so they +// can be retrieved cheaply without scanning the whole unread set, and so bulk +// archive/delete never touch them. 'notify get' unions this with unread so the +// legacy nchan poller still surfaces them. +$active = "$path/active"; $agents_dir = "/boot/config/plugins/dynamix/notifications/agents"; if (is_dir($agents_dir)) { $agents = []; @@ -160,13 +165,14 @@ if (is_dir($agents_dir)) { switch ($argv[1][0]=='-' ? 'add' : $argv[1]) { case 'init': - $files = glob("$unread/*.notify", GLOB_NOSORT); + $files = array_merge(glob("$unread/*.notify", GLOB_NOSORT) ?: [], glob("$active/*.notify", GLOB_NOSORT) ?: []); foreach ($files as $file) if (!is_readable($file)) chmod($file,0666); break; case 'smtp-init': @mkdir($unread,0755,true); @mkdir($archive,0755,true); + @mkdir($active,0755,true); $conf = []; $conf[] = "# Generated settings:"; $conf[] = "Root={$ssmtp['root']}"; @@ -189,6 +195,7 @@ case 'smtp-init': case 'cron-init': @mkdir($unread,0755,true); @mkdir($archive,0755,true); + @mkdir($active,0755,true); $text = empty($notify['status']) ? "" : "# Generated array status check schedule:\n{$notify['status']} $docroot/plugins/dynamix/scripts/statuscheck &> /dev/null\n\n"; parse_cron_cfg("dynamix", "status-check", $text); $text = empty($notify['unraidos']) ? "" : "# Generated Unraid OS update check schedule:\n{$notify['unraidos']} $docroot/plugins/dynamix.plugin.manager/scripts/unraidcheck &> /dev/null\n\n"; @@ -269,14 +276,20 @@ case 'add': // Condition-style notifications use a stable key for the filename so raising the // same condition again overwrites it (idempotent) instead of stacking duplicates. $idBase = $key !== '' ? $key : "{$event}-{$ticket}"; - $unread = "{$unread}/".safe_filename("{$idBase}.notify"); - $archive = "{$archive}/".safe_filename("{$idBase}.notify"); + $fileName = safe_filename("{$idBase}.notify"); + // Persistent notifications live in 'active'; transient ones in 'unread'. + $targetDir = $persistent ? $active : $unread; + $unreadFile = "{$targetDir}/{$fileName}"; + $archiveFile = "{$archive}/{$fileName}"; // Legacy dedup: don't recreate a notification the user already archived. // Keyed (condition-style) notifications are exempt: re-raising a condition should // re-pin it, so clear any stale archived copy and proceed instead of bailing. - if (file_exists($archive)) { - if ($key !== '') @unlink($archive); else break; + if (file_exists($archiveFile)) { + if ($key !== '') @unlink($archiveFile); else break; } + // Clear any stale copy in the other store (e.g. a persistent notification that was + // previously written to unread before being relocated to active, or vice versa). + @unlink((($persistent ? $unread : $active))."/{$fileName}"); $entity = $overrule===false ? $notify[$importance] : $overrule; $cleanSubject = clean_subject($subject); $archiveData = [ @@ -289,7 +302,7 @@ case 'add': if ($message) $archiveData['message'] = str_replace('\n','
',$message); // Persistent (condition-style) notifications are never archived - they clear when // their condition resolves - so skip writing the archive copy for them. - if (!$mailtest && !$persistent) file_put_contents($archive, build_ini_string($archiveData)); + if (!$mailtest && !$persistent) file_put_contents($archiveFile, build_ini_string($archiveData)); if (($entity & 1)==1 && !$mailtest && !$noBrowser) { $unreadData = [ 'timestamp' => $timestamp, @@ -301,10 +314,11 @@ case 'add': ]; if ($key !== '') $unreadData['key'] = $key; if ($persistent) $unreadData['persistent'] = 'true'; - // Generation stamp for JS-sourced banner notifications: lets 'banner-sweep' - // reconcile (clear) banners that were not re-raised on the latest page load. + // Generation stamp for JS-sourced banner notifications: lets reconciliation + // clear banners that were not re-raised on the latest page load. if ($gen !== '') $unreadData['gen'] = $gen; - file_put_contents($unread, build_ini_string($unreadData)); + @mkdir($targetDir, 0755, true); + file_put_contents($unreadFile, build_ini_string($unreadData)); } if (($entity & 2)==2 || $mailtest) generate_email($event, $cleanSubject, str_replace('
','. ',$description), $importance, $message, $recipients, $fqdnlink); if (($entity & 4)==4 && !$mailtest) { if (is_array($agents)) {foreach ($agents as $agent) {exec("TIMESTAMP='$timestamp' EVENT=".escapeshellarg($event)." SUBJECT=".escapeshellarg($cleanSubject)." DESCRIPTION=".escapeshellarg($description)." IMPORTANCE=".escapeshellarg($importance)." CONTENT=".escapeshellarg($message)." LINK=".escapeshellarg($fqdnlink)." bash ".$agent);};}}; @@ -313,7 +327,9 @@ case 'add': case 'get': $output = []; $json = []; - $files = glob("$unread/*.notify", GLOB_NOSORT); + // Union persistent ('active') with transient ('unread') so the legacy nchan + // poller and any 'notify get' consumer still see condition-style notifications. + $files = array_merge(glob("$unread/*.notify", GLOB_NOSORT) ?: [], glob("$active/*.notify", GLOB_NOSORT) ?: []); usort($files, function($a,$b){return filemtime($a)-filemtime($b);}); $i = 0; foreach ($files as $file) { @@ -352,7 +368,9 @@ case 'clear': } if ($key === '') exit(usage()); $name = safe_filename("{$key}.notify"); - foreach ([$unread, $archive] as $dir) { + // Sweep all stores: persistent ('active') is the usual home, but also clear any + // stale unread/archive twin left from before the active/ split or a prior raise. + foreach ([$active, $unread, $archive] as $dir) { $target = "$dir/$name"; if (strpos(realpath($target) ?: '', $dir.'/')===0) @unlink($target); } From 20fa7a6314aae86512aed9b94b1d35e8cf1a804a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 23 Jun 2026 13:23:58 -0400 Subject: [PATCH 10/10] fix(notify): always store condition notifications off-disk in tmpfs Condition-style ("active") notifications are derived state, re-raised on every page load. They were written to $path/active, but $path follows the user-facing "Store notifications to boot drive" setting -- so on systems with that enabled, a condition notice was persisted to flash and stranded forever once its condition was gone (e.g. the plugin that raised it was removed). Hardcode the active dir to /tmp/notifications/active, decoupled from $path. A reboot now wipes condition notifications and the next page load re-raises only those still true -- making "stuck forever" structurally impossible without any generation-sweep reconciliation. Event notifications (unread/archive) still honor the flash setting via $path. Co-Authored-By: Claude Opus 4.8 --- emhttp/plugins/dynamix/scripts/notify | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify index dbc88b3eba..06d0ec8bf5 100755 --- a/emhttp/plugins/dynamix/scripts/notify +++ b/emhttp/plugins/dynamix/scripts/notify @@ -148,11 +148,18 @@ extract(parse_plugin_cfg("dynamix",true)); $path = _var($notify,'path','/tmp/notifications'); $unread = "$path/unread"; $archive = "$path/archive"; -// Persistent (condition-style) notifications live in their own directory so they -// can be retrieved cheaply without scanning the whole unread set, and so bulk -// archive/delete never touch them. 'notify get' unions this with unread so the -// legacy nchan poller still surfaces them. -$active = "$path/active"; +// Condition-style notifications (e.g. "Modified GUI installed") are derived state: +// true only while their condition holds, and re-raised on every page load. They live +// in their own directory so they can be retrieved cheaply without scanning the whole +// unread set, and so bulk archive/delete never touch them. 'notify get' unions this +// with unread so the legacy nchan poller still surfaces them. +// +// This directory is ALWAYS tmpfs, never the user-configurable $path (which may point +// at the flash boot device when "Store notifications to boot drive" is set). Persisting +// a derived condition across reboots is what strands it forever when the condition is +// gone (e.g. the plugin was removed); keeping it off-disk makes a reboot free GC — the +// next page load re-raises only conditions that are still true. +$active = "/tmp/notifications/active"; $agents_dir = "/boot/config/plugins/dynamix/notifications/agents"; if (is_dir($agents_dir)) { $agents = [];