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: '', + 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="") { - 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 +559,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 +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);