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"
diff --git a/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php b/emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php
index 66b457448f..8ebf5dbd78 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 -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/include/DefaultPageLayout/HeadInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
index f8b697a55c..b366980f07 100644
--- a/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
+++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/HeadInlineJS.php
@@ -379,35 +379,103 @@ 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 }
+// 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.
+// 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;
+ 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: '=_('System notice')?>',
+ d: '', // no description: the "Active" badge already conveys persistence
+ l: (parsed.link && parsed.link.href) ? parsed.link.href : '',
+ p: '1',
+ k: key,
+ g: bannerGen
+ });
+ 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 +500,12 @@ function showBannerWarnings() {
}
function addRebootNotice(message="=_('You must reboot for changes to take effect')?>") {
- 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="=_('You must reboot for changes to take effect')?>") {
- 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 +559,7 @@ function digits(number) {
function flashReport() {
$.post('/webGui/include/Report.php',{cmd:'config'},function(check){
- if (check>0) addBannerWarning("=_('Your boot drive is corrupted or offline').'. '._('Post your diagnostics in the forum for help').'.'?> =_('See also here')?>");
+ if (check>0) addBannerWarning("=_('Your boot drive is corrupted or offline').'. '._('Post your diagnostics in the forum for help').'.'?> =_('See also here')?>",true,true);
});
}
@@ -522,11 +588,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..272ee929d3 100644
--- a/emhttp/plugins/dynamix/include/Notify.php
+++ b/emhttp/plugins/dynamix/include/Notify.php
@@ -34,16 +34,26 @@
case 'd':
case 'i':
case 'm':
+ case 'k':
+ case 'l':
+ case 'g':
$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;
diff --git a/emhttp/plugins/dynamix/scripts/notify b/emhttp/plugins/dynamix/scripts/notify
index 1c4db802b5..06d0ec8bf5 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 << /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";
@@ -209,8 +228,11 @@ case 'add':
$mailtest = false;
$overrule = false;
$noBrowser = false;
+ $key = '';
+ $persistent = false;
+ $gen = '';
- $options = getopt("l:e:s:d:i:m:r:xtb");
+ $options = getopt("l:e:s:d:i:m:r:k:g:xtbp");
foreach ($options as $option => $value) {
switch ($option) {
case 'e':
@@ -241,6 +263,15 @@ case 'add':
case 'b':
$noBrowser = true;
break;
+ case 'k':
+ $key = $value;
+ break;
+ case 'p':
+ $persistent = true;
+ break;
+ case 'g':
+ $gen = $value;
+ break;
case 'l':
$nginx = (array)@parse_ini_file('/var/local/emhttp/nginx.ini');
$link = $value;
@@ -249,9 +280,23 @@ case 'add':
}
}
- $unread = "{$unread}/".safe_filename("{$event}-{$ticket}.notify");
- $archive = "{$archive}/".safe_filename("{$event}-{$ticket}.notify");
- if (file_exists($archive)) break;
+ // 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}";
+ $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($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 = [
@@ -262,7 +307,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($archiveFile, build_ini_string($archiveData));
if (($entity & 1)==1 && !$mailtest && !$noBrowser) {
$unreadData = [
'timestamp' => $timestamp,
@@ -272,7 +319,13 @@ case 'add':
'importance' => $importance,
'link' => $link,
];
- file_put_contents($unread, build_ini_string($unreadData));
+ if ($key !== '') $unreadData['key'] = $key;
+ if ($persistent) $unreadData['persistent'] = 'true';
+ // 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;
+ @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);};}};
@@ -281,7 +334,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) {
@@ -308,6 +363,25 @@ 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 (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");
+ // 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);
+ }
+ break;
}
exit(0);