From 4dfd3040dfef117315a96eb73745d45cd3e9dd7c Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:07:09 +0200 Subject: [PATCH 01/59] Enhance Discord agent: **UNTESTEDadd emhttp/plugins/dynamix/agents/Discord.xml* As mentioned in https://forums.unraid.net/topic/121039-syslog-notify-create-notifications-if-specific-words-occur-in-the-logs/#findComment-1577716 it is possible to pass unescaped content to the discord api request. By using jq this should be solved. **UNTESTEDadd emhttp/plugins/dynamix/agents/Discord.xml* --- emhttp/plugins/dynamix/agents/Discord.xml | 260 ++++++++++++---------- 1 file changed, 146 insertions(+), 114 deletions(-) diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index 652af1218..c298fac3b 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -23,7 +23,6 @@ # # If a notification does not go through, check the /var/log/notify_Discord file for hints ############ - ############ # Discord webhooks docs: https://birdie0.github.io/discord-webhooks-guide/ # @@ -36,31 +35,37 @@ # CONTENT (notify -m) # LINK (notify -l) # TIMESTAMP (seconds from epoch) - SCRIPTNAME=$(basename "$0") LOG="/var/log/notify_${SCRIPTNAME%.*}" - # for quick test, setup environment to mimic notify script -[[ -z "${EVENT}" ]] && EVENT='Unraid Status' -[[ -z "${SUBJECT}" ]] && SUBJECT='Notification' -[[ -z "${DESCRIPTION}" ]] && DESCRIPTION='No description' -[[ -z "${IMPORTANCE}" ]] && IMPORTANCE='normal' -[[ -z "${TIMESTAMP}" ]] && TIMESTAMP=$(date +%s) +EVENT="${EVENT:-Unraid Status}" +SUBJECT="${SUBJECT:-Notification}" +DESCRIPTION="${DESCRIPTION:-No description}" +IMPORTANCE="${IMPORTANCE:-normal}" +CONTENT="${CONTENT:-}" +LINK="${LINK:-}" +HOSTNAME="${HOSTNAME:-$(hostname)}" +TIMESTAMP="${TIMESTAMP:-$(date +%s)}" + # ensure link has a host if [[ -n "${LINK}" ]] && [[ ${LINK} != http* ]]; then -source <(grep "NGINX_DEFAULTURL" /usr/local/emhttp/state/nginx.ini 2>/dev/null) -LINK=${NGINX_DEFAULTURL}${LINK} + if [[ -r /usr/local/emhttp/state/nginx.ini ]]; then + # shellcheck disable=SC1090 + source <(grep "NGINX_DEFAULTURL" /usr/local/emhttp/state/nginx.ini || true) + LINK="${NGINX_DEFAULTURL}${LINK}" + fi fi + # Discord will not allow links with bare hostname, links must have both hostname and tld or no link at all if [[ -n "${LINK}" ]]; then -HOST=$(echo "${LINK}" | cut -d'/' -f3) -[[ ${HOST} != *.* ]] && LINK= + HOST=$(echo "${LINK}" | cut -d'/' -f3) + [[ ${HOST} != *.* ]] && LINK= fi # note: there is no default for CONTENT - # send DESCRIPTION and/or CONTENT. Ignore the default DESCRIPTION. [[ "${DESCRIPTION}" == 'No description' ]] && DESCRIPTION="" +FULL_DETAILS="" if [[ -n "${DESCRIPTION}" ]] && [[ -n "${CONTENT}" ]]; then FULL_DETAILS="${DESCRIPTION}\n\n${CONTENT}" elif [[ -n "${DESCRIPTION}" ]]; then @@ -68,132 +73,159 @@ elif [[ -n "${DESCRIPTION}" ]]; then elif [[ -n "${CONTENT}" ]]; then FULL_DETAILS="${CONTENT}" fi -# split into 1024 character segments -[[ -n "${FULL_DETAILS}" ]] && DESC_FIELD=$( - cat <\"," - fi + normal) + THUMBNAIL="https://craftassets.unraid.net/uploads/discord/notify-normal.png" + COLOR="39208" + ;; + warning) + THUMBNAIL="https://craftassets.unraid.net/uploads/discord/notify-warning.png" + COLOR="16747567" + ;; + alert) + THUMBNAIL="https://craftassets.unraid.net/uploads/discord/notify-alert.png" + COLOR="14821416" + ;; + *) + IMPORTANCE="normal" + THUMBNAIL="https://craftassets.unraid.net/uploads/discord/notify-normal.png" + COLOR="39208" ;; esac +# @mentions only for alert +CONTENT_AREA="" +if [[ "${IMPORTANCE}" == "alert" ]]; then + if [[ -n "${DISCORD_TAG_ID}" && "${DISCORD_TAG_ID}" != "none" ]]; then + # add missing "@" + [[ "${DISCORD_TAG_ID:0:1}" != "@" ]] && DISCORD_TAG_ID="@${DISCORD_TAG_ID}" + CONTENT_AREA="<${DISCORD_TAG_ID}>" + fi +fi + # https://birdie0.github.io/discord-webhooks-guide/structure/embed/author.html # if SERVER_ICON is defined, use it -[[ -n "${SERVER_ICON}" && "${SERVER_ICON:0:8}" == "https://" ]] && ICON_URL="\"icon_url\": \"${SERVER_ICON}\"," +ICON_URL="" +if [[ -n "${SERVER_ICON}" && "${SERVER_ICON}" == "https://"* ]]; then + ICON_URL="$SERVER_ICON" +fi -# https://birdie0.github.io/discord-webhooks-guide/structure/embed/url.html -# if LINK is defined, use it -[[ -n "${LINK}" ]] && LINK_URL="\"url\": \"${LINK}\"," +# shellcheck disable=SC2016 +jq_filter=' + # basic object + { + embeds: [ + { + title: $event | tostring, + description: $subject | tostring, + timestamp: $ts | tostring, + color: ($color | tonumber), + author: { + name: $hostname + }, + thumbnail: { url: $thumb }, + fields: [] + } + ] + } -DATA=$( - cat < 0 then . + {content: $content_area} else . end + + # Optional: URL + | if ($link | length) > 0 then .embeds[0].url = $link else . end + + # Optional: icon_url + | if ($icon | length) > 0 then .embeds[0].author.icon_url = $icon else . end + + # Description + | .embeds[0].fields += [ { name: "Description", value: $desc_field } ] + | if ($desc_field2 | length) > 0 then .embeds[0].fields += [ { name: "Description (cont)", value: $desc_field2 } ] + | if ($desc_field3 | length) > 0 then .embeds[0].fields += [ { name: "Description (cont)", value: $desc_field3 } ] + + # Priority + | .embeds[0].fields += [ { name: "Priority", value: $importance, inline: true } ] +' + +args=( + -n + --arg event "${EVENT:0:256}" + --arg subject "${SUBJECT:0:2043}" + --arg ts "$ISO_TS" + --arg color "$COLOR" + --arg hostname "$HOSTNAME" + --arg thumb "$THUMBNAIL" + --arg link "$LINK" + --arg icon "$ICON_URL" + --arg importance "$IMPORTANCE" + --arg desc_field "$DESC_FIELD" + --arg desc_field2 "$DESC_FIELD2" + --arg desc_field3 "$DESC_FIELD3" + --arg content_area "${CONTENT_AREA}" + "$jq_filter" ) - -# echo "${DATA}" >>"${LOG}" +data_binary=$(jq "${args[@]}") # try several times in case we are being rate limited # this is not foolproof, messages can still be rejected +args=( + -s + -X POST "$WEBH_URL" + -H 'Content-Type: application/json' + --data-binary "$data_binary" +) MAX=4 -for ((i = 1; i <= "${MAX}"; i++)); do - RET=$( - curl -s -X "POST" "$WEBH_URL" -H 'Content-Type: application/json' --data-ascii @- <>"${LOG}" + echo "attempt $i of $MAX failed" + echo "$ret" + } >>"$LOG" + # if there was an error with the submission, log details and exit loop - [[ "${RET}" != *"retry_after"* ]] && echo "${DATA}" >>"${LOG}" && logger -t "${SCRIPTNAME}" -- "Failed sending notification" && break + if [[ "$ret" != *"retry_after"* ]]; then + echo "$payload" >>"$LOG" + logger -t "$SCRIPTNAME" -- "Failed sending notification" + break + fi + # if retries exhausted, log failure - [[ "${i}" -eq "${MAX}" ]] && echo "${DATA}" >>"${LOG}" && logger -t "${SCRIPTNAME}" -- "Failed sending notification - rate limited" && break + if (( i == MAX )); then + echo "$payload" >>"$LOG" + logger -t "$SCRIPTNAME" -- "Failed sending notification - rate limited" + break + fi + # we were rate limited, try again after a delay sleep 1 + done ]]> - + \ No newline at end of file From 5eb2fb7a337227bcd5dc97ea2154b30d1e5a2cff Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:23:10 +0200 Subject: [PATCH 02/59] Correct third-segment length check. Fix undefined variable in failure logging --- emhttp/plugins/dynamix/agents/Discord.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index c298fac3b..aa3839e7d 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -80,7 +80,7 @@ if [[ "${FULL_DETAILS}" ]]; then DESC_FIELD="${FULL_DETAILS:0:1024}" if [[ ${#FULL_DETAILS} -gt 1024 ]]; then DESC_FIELD2="${FULL_DETAILS:1024:1024}" - if [[ ${#FULL_DETAILS} -gt 1024 ]]; then + if [[ ${#FULL_DETAILS} -gt 2048 ]]; then DESC_FIELD3="${FULL_DETAILS:2048:1024}" fi fi @@ -210,14 +210,14 @@ for ((i=1; i<=MAX; i++)); do # if there was an error with the submission, log details and exit loop if [[ "$ret" != *"retry_after"* ]]; then - echo "$payload" >>"$LOG" + echo "$data_binary" >>"$LOG" logger -t "$SCRIPTNAME" -- "Failed sending notification" break fi # if retries exhausted, log failure if (( i == MAX )); then - echo "$payload" >>"$LOG" + echo "$data_binary" >>"$LOG" logger -t "$SCRIPTNAME" -- "Failed sending notification - rate limited" break fi From 2d19edce948f3735070ed9a75353cfd208324320 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:29:10 +0200 Subject: [PATCH 03/59] Added some linebreaks from the original file --- emhttp/plugins/dynamix/agents/Discord.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index aa3839e7d..76dcdc780 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -23,6 +23,7 @@ # # If a notification does not go through, check the /var/log/notify_Discord file for hints ############ + ############ # Discord webhooks docs: https://birdie0.github.io/discord-webhooks-guide/ # @@ -35,8 +36,10 @@ # CONTENT (notify -m) # LINK (notify -l) # TIMESTAMP (seconds from epoch) + SCRIPTNAME=$(basename "$0") LOG="/var/log/notify_${SCRIPTNAME%.*}" + # for quick test, setup environment to mimic notify script EVENT="${EVENT:-Unraid Status}" SUBJECT="${SUBJECT:-Notification}" From f413a1e8e03d36d1b71b1aa071d75b3392b88351 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:37:00 +0200 Subject: [PATCH 04/59] Added final line break --- emhttp/plugins/dynamix/agents/Discord.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index 76dcdc780..75bd9a0b9 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -231,4 +231,4 @@ for ((i=1; i<=MAX; i++)); do done ]]> - \ No newline at end of file + From 43db104c46a8bab8e976395249e32021d1d23c1a Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:52:05 +0200 Subject: [PATCH 05/59] Fixed Newlines are literal, Catch jq errors before creating curl request --- emhttp/plugins/dynamix/agents/Discord.xml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index 75bd9a0b9..b4700f0aa 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -70,7 +70,7 @@ fi [[ "${DESCRIPTION}" == 'No description' ]] && DESCRIPTION="" FULL_DETAILS="" if [[ -n "${DESCRIPTION}" ]] && [[ -n "${CONTENT}" ]]; then - FULL_DETAILS="${DESCRIPTION}\n\n${CONTENT}" + FULL_DETAILS="${DESCRIPTION}"$'\n\n'"${CONTENT}" elif [[ -n "${DESCRIPTION}" ]]; then FULL_DETAILS="${DESCRIPTION}" elif [[ -n "${CONTENT}" ]]; then @@ -169,6 +169,7 @@ jq_filter=' | .embeds[0].fields += [ { name: "Priority", value: $importance, inline: true } ] ' +# create valid json args=( -n --arg event "${EVENT:0:256}" @@ -186,7 +187,13 @@ args=( --arg content_area "${CONTENT_AREA}" "$jq_filter" ) -data_binary=$(jq "${args[@]}") +json=$(jq "${args[@]}" 2>&1) +jq_status=$? +if [[ "$jq_status" -ne 0 ]]; then + echo "jq error $json" >>"$LOG" + logger -t "$SCRIPTNAME" -- "Failed sending notification ($json)" + exit 1 +fi # try several times in case we are being rate limited # this is not foolproof, messages can still be rejected @@ -194,7 +201,7 @@ args=( -s -X POST "$WEBH_URL" -H 'Content-Type: application/json' - --data-binary "$data_binary" + --data-binary "$json" ) MAX=4 for ((i=1; i<=MAX; i++)); do @@ -213,14 +220,14 @@ for ((i=1; i<=MAX; i++)); do # if there was an error with the submission, log details and exit loop if [[ "$ret" != *"retry_after"* ]]; then - echo "$data_binary" >>"$LOG" + echo "$json" >>"$LOG" logger -t "$SCRIPTNAME" -- "Failed sending notification" break fi # if retries exhausted, log failure if (( i == MAX )); then - echo "$data_binary" >>"$LOG" + echo "$json" >>"$LOG" logger -t "$SCRIPTNAME" -- "Failed sending notification - rate limited" break fi From a5d34cb8a56a7decffd4b0cf1e0872f855dc19a1 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:58:13 +0200 Subject: [PATCH 06/59] Enhance filtering of discord id --- emhttp/plugins/dynamix/agents/Discord.xml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index b4700f0aa..52ccd3582 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -119,9 +119,12 @@ esac CONTENT_AREA="" if [[ "${IMPORTANCE}" == "alert" ]]; then if [[ -n "${DISCORD_TAG_ID}" && "${DISCORD_TAG_ID}" != "none" ]]; then - # add missing "@" - [[ "${DISCORD_TAG_ID:0:1}" != "@" ]] && DISCORD_TAG_ID="@${DISCORD_TAG_ID}" - CONTENT_AREA="<${DISCORD_TAG_ID}>" + id="${DISCORD_TAG_ID}" + # Strip surrounding angle brackets if present + [[ "$id" == \<*\> ]] && id="${id:1:${#id}-2}" + # If it already starts with @, @! or @& keep it; else prefix @ (user) + [[ "$id" =~ ^@(|!|&)[0-9]+$ ]] || id="@${id}" + CONTENT_AREA="<${id}>" fi fi From 3b239b4de0d922f51abf631f35b47b58c927dcd6 Mon Sep 17 00:00:00 2001 From: poroyo <132068975+poroyo@users.noreply.github.com> Date: Mon, 8 Sep 2025 04:22:18 +0900 Subject: [PATCH 07/59] fix: stop spinner & show error on File Manager Calculator failure --- emhttp/plugins/dynamix/Browse.page | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page index d67495468..e67d655b2 100644 --- a/emhttp/plugins/dynamix/Browse.page +++ b/emhttp/plugins/dynamix/Browse.page @@ -459,6 +459,10 @@ function doAction(action, title, id) { clearTimeout(timers.calc); $('div.spinner.fixed').hide('slow'); swal({title:"_(Calculate Occupied Space)_",text:text,html:true,confirmButtonText:"_(Ok)_"}); + }).fail(function(xhr){ + clearTimeout(timers.calc); + $('div.spinner.fixed').hide('slow'); + swal({title:"Error", text:"Calculate failed: "+xhr.status+" "+xhr.statusText, type:"error"}); }); return; case 15: // search @@ -735,6 +739,10 @@ function doActions(action, title) { clearTimeout(timers.calc); $('div.spinner.fixed').hide('slow'); swal({title:"_(Calculate Occupied Space)_",text:text,html:true,confirmButtonText:"_(Ok)_"}); + }).fail(function(xhr){ + clearTimeout(timers.calc); + $('div.spinner.fixed').hide('slow'); + swal({title:"Error", text:"Calculate failed: "+xhr.status+" "+xhr.statusText, type:"error"}); }); return; case 15: // search From f7bf7bc1bb315a128c4772b3aecd571eae7b80af Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 15:41:53 -0400 Subject: [PATCH 08/59] feat: swap to smoothiecharts --- emhttp/plugins/dynamix/DashStats.page | 256 ++-- emhttp/plugins/dynamix/javascript/smoothie.js | 1176 +++++++++++++++++ 2 files changed, 1276 insertions(+), 156 deletions(-) create mode 100644 emhttp/plugins/dynamix/javascript/smoothie.js diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index c1d4203ed..22cdff963 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -196,7 +196,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout ?> - + @@ -412,7 +412,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout ?> -
+ @@ -584,7 +584,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout ?> -
+ @@ -1436,56 +1436,77 @@ var stopgap = ' var recall = null; var recover = null; -var options_cpu = { - series:[{name:'load', data:cpu.slice()}], - chart:{height:120, type:'line', fontFamily:'clear-sans', animations:{enabled:true, easing:'linear', dynamicAnimation:{speed:980}}, toolbar:{show:false}, zoom:{enabled:false},events:{updated:function(){if (cpuchart.customData.updateCount == 0) {cpuchart.customData.animationPending = false}cpuchart.customData.updateCount++;},animationEnd:function(){cpuchart.customData.animationPending = false;updateCPUChart();}}}, - dataLabels:{enabled:false}, - tooltip:{enabled:false}, - stroke:{curve:'smooth', width:1}, - colors:['#ff8c2f'], - markers:{size:0}, - xaxis:{type:'datetime', range:cpuline-1, labels:{show:false}, axisTicks:{show:false}, axisBorder:{show:false}}, - yaxis:{max:100, min:0, tickAmount:4, labels:{formatter:function(v,i){return v.toFixed(0)+' %';}, style:{colors:''}}, axisBorder:{show:false}, axisTicks:{show:false}}, - grid:{show:true, borderColor:''}, - legend:{show:false} -}; -var options_net = { - series:[{name:'receive', data:rxd.slice()},{name:'transmit', data:txd.slice()}], - chart:{height:120, type:'line', fontFamily:'clear-sans', animations:{enabled:true, easing:'linear', dynamicAnimation:{speed:980}}, toolbar:{show:false}, zoom:{enabled:false},events:{updated:function(){if (netchart.customData.updateCount == 0) {netchart.customData.animationPending = false}netchart.customData.updateCount++;},animationEnd:function(){netchart.customData.animationPending = false;updateNetChart();}}}, - dataLabels:{enabled:false}, - tooltip:{enabled:false}, - stroke:{curve:'smooth', width:1}, - colors:['#e22828','#ff8c2f'], - markers:{size:0}, - xaxis:{type:'datetime', range:netline-1, labels:{show:false}, axisTicks:{show:false}, axisBorder:{show:false}}, - yaxis:{tickAmount:4, labels:{formatter:function(v,i){return autoscale(v,'bps',1);}, style:{colors:''}}, axisBorder:{show:false}, axisTicks:{show:false}}, - grid:{show:true, borderColor:''}, - legend:{show:false} -}; +// SmoothieCharts initialization +var cpuchart = new SmoothieChart({ + millisPerPixel: 100, + minValue: 0, + maxValue: 100, + grid: { + strokeStyle: '', + fillStyle: 'transparent', + lineWidth: 1, + millisPerLine: 5000, + verticalSections: 4 + }, + labels: { + fillStyle: '', + fontSize: 11, + precision: 0 + }, + timestampFormatter: function(date) { return ''; }, + yRangeFunction: function(range) { + return { min: 0, max: 100 }; + } +}); -var cpuchart = new ApexCharts(document.querySelector('#cpuchart'), options_cpu); -var netchart = new ApexCharts(document.querySelector('#netchart'), options_net); +var netchart = new SmoothieChart({ + millisPerPixel: 100, + minValue: 0, + grid: { + strokeStyle: '', + fillStyle: 'transparent', + lineWidth: 1, + millisPerLine: 5000, + verticalSections: 4 + }, + labels: { + fillStyle: '', + fontSize: 11, + placement: 'left' + }, + timestampFormatter: function(date) { return ''; }, + maxValueScale: 1.0, + yMaxFormatter: function(value) { + if (value >= 1000) { + return Math.floor(value / 1000) + ' Mbps'; + } + return Math.floor(value) + ' kbps'; + }, + yMinFormatter: function(value) { + return Math.floor(value) + ' kbps'; + } +}); -// Add custom global variable to ncharts (won't affect ApexCharts functionality) +// Create TimeSeries for CPU and Network data +var cpuTimeSeries = new TimeSeries(); +var rxTimeSeries = new TimeSeries(); +var txTimeSeries = new TimeSeries(); + +// Add TimeSeries to charts with colors +cpuchart.addTimeSeries(cpuTimeSeries, { strokeStyle: '#ff8c2f', lineWidth: 1 }); +netchart.addTimeSeries(rxTimeSeries, { strokeStyle: '#e22828', lineWidth: 1 }); +netchart.addTimeSeries(txTimeSeries, { strokeStyle: '#ff8c2f', lineWidth: 1 }); + +// Custom data for chart state management netchart.customData = { - isVisible: false, - BrowserVisibility: true, - animationPending: false, - netAnimationInterval: null, - newData: false, - updateCount: 0, - initialized: false + isVisible: false }; cpuchart.customData = { isVisible: false, - BrowserVisibility: true, - animationPending: false, - cpuAnimationInterval: null, coresVisible: false, - newData: false, - updateCount: 0, - initialized: false + cpuData: null, + cpuLoad: 0 }; $(function() { @@ -1495,9 +1516,9 @@ $(function() { entries.forEach(entry => { if (entry.target === netchartElement) { netchart.customData.isVisible = entry.isIntersecting; - // Reset the update count as the chart doesn't always fire animationEnd in this case + // Chart visibility changed if (netchart.customData.isVisible) { - resetNetUpdateCount(); + // SmoothieCharts handles visibility automatically } } }); @@ -1520,9 +1541,9 @@ $(function() { entries.forEach(entry => { if (entry.target === cpuchartElement) { cpuchart.customData.isVisible = entry.isIntersecting; - // Reset the update count as the chart doesn't always fire animationEnd in this case + // Chart visibility changed if (cpuchart.customData.isVisible) { - resetCPUUpdateCount(); + // SmoothieCharts handles visibility automatically } } }); @@ -1588,8 +1609,7 @@ let viewportResizeTimeout; window.addEventListener('resize', function(){ clearTimeout(viewportResizeTimeout); viewportResizeTimeout = setTimeout(function(){ - resetNetUpdateCount(); - resetCPUUpdateCount(); + // SmoothieCharts handles resize automatically }, 250); // Wait 250ms after resize stops before executing }); @@ -1627,80 +1647,37 @@ function sanitizeMultiCookie(cookieName, delimiter, removeDuplicates=false) { } function initCharts(clear) { - // initialize graphs entries - var data = []; - data.cpu = data.rxd = data.txd = ""; - - var now = new Date().getTime(); - if (!clear) { - var c = data.cpu.split(';'); - var r = data.rxd.split(';'); - var t = data.txd.split(';'); - for (var i=0; i < cpuline; i++) { - var x = now + i; - var y = c[i]||0; cpu.push({x,y}); - } - cputime = x + 1; - } else { - // clear network graph - var r = ''; var t = ''; - rxd = []; txd = []; + // SmoothieCharts manages its own data through TimeSeries + if (clear) { + // Clear the TimeSeries data if needed + rxTimeSeries.clear(); + txTimeSeries.clear(); } - for (var i=0; i < netline; i++) { - var x = now + i; - var y = r[i]||0; rxd.push({x,y}); - var y = t[i]||0; txd.push({x,y}); - } - nettime = x + 1; } function resetCharts() { - // prevent unlimited graph growing - limit to 300 (5 minutes) of data - cpu = cpu.slice(-300); - rxd = rxd.slice(-300); - txd = txd.slice(-300); + // SmoothieCharts automatically manages data retention + // No manual cleanup needed } function addChartCpu(load) { - cputime++; - var i = cpu.length - cpuline; - if (i > 0) { // clear value outside graph - i = i - 1; - cpu[i].x = cputime - cpuline; - cpu[i].y = 0; - } - cpu.push({x:cputime, y:load}); + // Add data point to SmoothieCharts TimeSeries + cpuTimeSeries.append(new Date().getTime(), load); } function addChartNet(rx, tx) { - nettime++; - var i = rxd.length - netline; - if (i > 0) { // clear value outside graph - i = i - 1; - rxd[i].x = nettime - netline; - rxd[i].y = 0; - txd[i].x = nettime - netline; - txd[i].y = 0; - } - rxd.push({x:nettime, y:rx}); - txd.push({x:nettime, y:tx}); + // Add data points to SmoothieCharts TimeSeries (convert bps to kbps) + var now = new Date().getTime(); + rxTimeSeries.append(now, rx / 1000); + txTimeSeries.append(now, tx / 1000); } -function resetCPUUpdateCount() { - cpuchart.customData.updateCount = 0; - cpuchart.customData.animationPending = false; -} - -function resetNetUpdateCount() { - netchart.customData.updateCount = 0; - netchart.customData.animationPending = false; -} function updateCPUBarCharts() { if (!isPageVisible()) { return; } // prevent an initial JS error if the first datapoint isn't available yet - // (cpuchart.customData.newData is reset by the updateCPUChart function so can't be used) + // Update CPU bar charts based on current data const customData = cpuchart.customData; if (!customData.cpuData?.cpus || typeof customData.cpuLoad === 'undefined') { return; @@ -1760,39 +1737,10 @@ function updateCPUBarCharts() { } } -function updateCPUChart() { - if (!cpuchart.customData.newData || !cpuchart.customData.isVisible || !isPageVisible()) { - return; - } - // Check if the animation is still running and a timeout hasn't been set - if (!cpuchart.customData.animationPending) { - // No animation running. Clear out the timeout and update the chart - cpuchart.customData.animationPending = true; - cpuchart.customData.newData = false; - cpuchart.updateSeries([{data:cpu}]); - } -} - -function updateNetChart() { - if (!netchart.customData.newData || !netchart.customData.isVisible || !isPageVisible()) { - return; - } - // Check if the animation is still running and a timeout hasn't been set - if (!netchart.customData.animationPending) { - // No animation running. Clear out the timeout and update the chart - netchart.customData.animationPending = true; - netchart.customData.newData = false; - netchart.updateSeries([{data:rxd},{data:txd}]); - } -} function isPageVisible() { - // Check if charts are good to go - if (netchart.customData.initialized && cpuchart.customData.initialized) { - return !document.hidden; - } else { - return false; - } + // SmoothieCharts handles visibility automatically + return !document.hidden; } @@ -1908,18 +1856,18 @@ function changeCPUline(val) { cpuline = val; if (val==30) delete cookie.cpuline; else cookie.cpuline = val; saveCookie(); - // Reset the update count as the chart doesn't always fire animationEnd - resetCPUUpdateCount(); - cpuchart.updateOptions({xaxis:{range:cpuline-1}}); + // Update chart range + // Update SmoothieChart time window + cpuchart.options.millisPerPixel = (cpuline * 1000) / 600; } function changeNetline(val) { netline = val; if (val==30) delete cookie.netline; else cookie.netline = val; saveCookie(); - // Reset the update count as the chart doesn't always fire animationEnd - resetNetUpdateCount(); - netchart.updateOptions({xaxis:{range:netline-1}}); + // Update chart range + // Update SmoothieChart time window + netchart.options.millisPerPixel = (netline * 1000) / 600; } function smartMenu(table) { @@ -2588,9 +2536,7 @@ dashboard.on('message',function(msg,meta) { $('#inbound').text(port[1]); $('#outbound').text(port[2]); // update the netchart but only send to ApexCharts if the chart is visible - addChartNet(port[3], port[4]); - netchart.customData.newData = true; - updateNetChart(); + addChartNet(port[3], port[4]); break; } @@ -2653,8 +2599,9 @@ $(function() { initCharts(); - cpuchart.render(); - netchart.render(); + // Start SmoothieCharts streaming + cpuchart.streamTo(document.getElementById('cpuchart'), 1000); + netchart.streamTo(document.getElementById('netchart'), 1000); addProperties(); @@ -2688,11 +2635,9 @@ $(function() { cpuchart.customData.cpuData = result.data.systemMetricsCpu; cpuchart.customData.cpuLoad = Math.round(cpuchart.customData.cpuData.percentTotal); - //update cpu chart data - addChartCpu(cpuchart.customData.cpuLoad); - cpuchart.customData.newData = true; + //update cpu chart data + addChartCpu(cpuchart.customData.cpuLoad); updateCPUBarCharts(); - updateCPUChart(); } }, @@ -2731,8 +2676,7 @@ $(function() { // Inhibit chart updates until DOM quiets down setTimeout(function() { - netchart.customData.initialized = true; - cpuchart.customData.initialized = true; + // Charts initialized },500); // Cleanup GraphQL subscription on page unload diff --git a/emhttp/plugins/dynamix/javascript/smoothie.js b/emhttp/plugins/dynamix/javascript/smoothie.js new file mode 100644 index 000000000..2af8f1580 --- /dev/null +++ b/emhttp/plugins/dynamix/javascript/smoothie.js @@ -0,0 +1,1176 @@ +;(function(exports) { + +/** + * @license + * MIT License: + * + * Copyright (c) 2010-2013, Joe Walnes + * 2013-2018, Drew Noakes + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Smoothie Charts - http://smoothiecharts.org/ + * (c) 2010-2013, Joe Walnes + * 2013-2018, Drew Noakes + * + * v1.0: Main charting library, by Joe Walnes + * v1.1: Auto scaling of axis, by Neil Dunn + * v1.2: fps (frames per second) option, by Mathias Petterson + * v1.3: Fix for divide by zero, by Paul Nikitochkin + * v1.4: Set minimum, top-scale padding, remove timeseries, add optional timer to reset bounds, by Kelley Reynolds + * v1.5: Set default frames per second to 50... smoother. + * .start(), .stop() methods for conserving CPU, by Dmitry Vyal + * options.interpolation = 'bezier' or 'line', by Dmitry Vyal + * options.maxValue to fix scale, by Dmitry Vyal + * v1.6: minValue/maxValue will always get converted to floats, by Przemek Matylla + * v1.7: options.grid.fillStyle may be a transparent color, by Dmitry A. Shashkin + * Smooth rescaling, by Kostas Michalopoulos + * v1.8: Set max length to customize number of live points in the dataset with options.maxDataSetLength, by Krishna Narni + * v1.9: Display timestamps along the bottom, by Nick and Stev-io + * (https://groups.google.com/forum/?fromgroups#!topic/smoothie-charts/-Ywse8FCpKI%5B1-25%5D) + * Refactored by Krishna Narni, to support timestamp formatting function + * v1.10: Switch to requestAnimationFrame, removed the now obsoleted options.fps, by Gergely Imreh + * v1.11: options.grid.sharpLines option added, by @drewnoakes + * Addressed warning seen in Firefox when seriesOption.fillStyle undefined, by @drewnoakes + * v1.12: Support for horizontalLines added, by @drewnoakes + * Support for yRangeFunction callback added, by @drewnoakes + * v1.13: Fixed typo (#32), by @alnikitich + * v1.14: Timer cleared when last TimeSeries removed (#23), by @davidgaleano + * Fixed diagonal line on chart at start/end of data stream, by @drewnoakes + * v1.15: Support for npm package (#18), by @dominictarr + * Fixed broken removeTimeSeries function (#24) by @davidgaleano + * Minor performance and tidying, by @drewnoakes + * v1.16: Bug fix introduced in v1.14 relating to timer creation/clearance (#23), by @drewnoakes + * TimeSeries.append now deals with out-of-order timestamps, and can merge duplicates, by @zacwitte (#12) + * Documentation and some local variable renaming for clarity, by @drewnoakes + * v1.17: Allow control over font size (#10), by @drewnoakes + * Timestamp text won't overlap, by @drewnoakes + * v1.18: Allow control of max/min label precision, by @drewnoakes + * Added 'borderVisible' chart option, by @drewnoakes + * Allow drawing series with fill but no stroke (line), by @drewnoakes + * v1.19: Avoid unnecessary repaints, and fixed flicker in old browsers having multiple charts in document (#40), by @asbai + * v1.20: Add SmoothieChart.getTimeSeriesOptions and SmoothieChart.bringToFront functions, by @drewnoakes + * v1.21: Add 'step' interpolation mode, by @drewnoakes + * v1.22: Add support for different pixel ratios. Also add optional y limit formatters, by @copacetic + * v1.23: Fix bug introduced in v1.22 (#44), by @drewnoakes + * v1.24: Fix bug introduced in v1.23, re-adding parseFloat to y-axis formatter defaults, by @siggy_sf + * v1.25: Fix bug seen when adding a data point to TimeSeries which is older than the current data, by @Nking92 + * Draw time labels on top of series, by @comolosabia + * Add TimeSeries.clear function, by @drewnoakes + * v1.26: Add support for resizing on high device pixel ratio screens, by @copacetic + * v1.27: Fix bug introduced in v1.26 for non whole number devicePixelRatio values, by @zmbush + * v1.28: Add 'minValueScale' option, by @megawac + * Fix 'labelPos' for different size of 'minValueString' 'maxValueString', by @henryn + * v1.29: Support responsive sizing, by @drewnoakes + * v1.29.1: Include types in package, and make property optional, by @TrentHouliston + * v1.30: Fix inverted logic in devicePixelRatio support, by @scanlime + * v1.31: Support tooltips, by @Sly1024 and @drewnoakes + * v1.32: Support frame rate limit, by @dpuyosa + * v1.33: Use Date static method instead of instance, by @nnnoel + * Fix bug with tooltips when multiple charts on a page, by @jpmbiz70 + * v1.34: Add disabled option to TimeSeries, by @TechGuard (#91) + * Add nonRealtimeData option, by @annazhelt (#92, #93) + * Add showIntermediateLabels option, by @annazhelt (#94) + * Add displayDataFromPercentile option, by @annazhelt (#95) + * Fix bug when hiding tooltip element, by @ralphwetzel (#96) + * Support intermediate y-axis labels, by @beikeland (#99) + * v1.35: Fix issue with responsive mode at high DPI, by @drewnoakes (#101) + * v1.36: Add tooltipLabel to ITimeSeriesPresentationOptions. + * If tooltipLabel is present, tooltipLabel displays inside tooltip + * next to value, by @jackdesert (#102) + * Fix bug rendering issue in series fill when using scroll backwards, by @olssonfredrik + * Add title option, by @mesca + * Fix data drop stoppage by rejecting NaNs in append(), by @timdrysdale + * Allow setting interpolation per time series, by @WofWca (#123) + * Fix chart constantly jumping in 1-2 pixel steps, by @WofWca (#131) + * Fix a memory leak appearing when some `timeSeries.disabled === true`, by @WofWca (#132) + * Fix: make all lines sharp, remove the `grid.sharpLines` option by @WofWca (#134) + * Improve performance, by @WofWca (#135) + * Fix `this.delay` not being respected with `nonRealtimeData: true`, by @WofWca (#137) + * Fix series fill & stroke being inconsistent for last data time < render time, by @WofWca (#138) + * v1.36.1: Fix a potential XSS when `tooltipLabel` or `strokeStyle` are controlled by users, by @WofWca + * v1.36.2: fix: 1px lines jumping 1px left and right at rational `millisPerPixel`, by @WofWca + * perf: improve `render()` performane a bit, by @WofWca + * v1.37: Add `fillToBottom` option to fill timeSeries to 0 instead of to the bottom of the canvas, by @socketpair & @WofWca (#140) + */ + + // Date.now polyfill + Date.now = Date.now || function() { return new Date().getTime(); }; + + var Util = { + extend: function() { + arguments[0] = arguments[0] || {}; + for (var i = 1; i < arguments.length; i++) + { + for (var key in arguments[i]) + { + if (arguments[i].hasOwnProperty(key)) + { + if (typeof(arguments[i][key]) === 'object') { + if (arguments[i][key] instanceof Array) { + arguments[0][key] = arguments[i][key]; + } else { + arguments[0][key] = Util.extend(arguments[0][key], arguments[i][key]); + } + } else { + arguments[0][key] = arguments[i][key]; + } + } + } + } + return arguments[0]; + }, + binarySearch: function(data, value) { + var low = 0, + high = data.length; + while (low < high) { + var mid = (low + high) >> 1; + if (value < data[mid][0]) + high = mid; + else + low = mid + 1; + } + return low; + }, + // So lines (especially vertical and horizontal) look a) consistent along their length and b) sharp. + pixelSnap: function(position, lineWidth) { + if (lineWidth % 2 === 0) { + // Closest pixel edge. + return Math.round(position); + } else { + // Closest pixel center. + return Math.floor(position) + 0.5; + } + }, + }; + + /** + * Initialises a new TimeSeries with optional data options. + * + * Options are of the form (defaults shown): + * + *
+   * {
+   *   resetBounds: true,        // enables/disables automatic scaling of the y-axis
+   *   resetBoundsInterval: 3000 // the period between scaling calculations, in millis
+   * }
+   * 
+ * + * Presentation options for TimeSeries are specified as an argument to SmoothieChart.addTimeSeries. + * + * @constructor + */ + function TimeSeries(options) { + this.options = Util.extend({}, TimeSeries.defaultOptions, options); + this.disabled = false; + this.clear(); + } + + TimeSeries.defaultOptions = { + resetBoundsInterval: 3000, + resetBounds: true + }; + + /** + * Clears all data and state from this TimeSeries object. + */ + TimeSeries.prototype.clear = function() { + this.data = []; + this.maxValue = Number.NaN; // The maximum value ever seen in this TimeSeries. + this.minValue = Number.NaN; // The minimum value ever seen in this TimeSeries. + }; + + /** + * Recalculate the min/max values for this TimeSeries object. + * + * This causes the graph to scale itself in the y-axis. + */ + TimeSeries.prototype.resetBounds = function() { + if (this.data.length) { + // Walk through all data points, finding the min/max value + this.maxValue = this.data[0][1]; + this.minValue = this.data[0][1]; + for (var i = 1; i < this.data.length; i++) { + var value = this.data[i][1]; + if (value > this.maxValue) { + this.maxValue = value; + } + if (value < this.minValue) { + this.minValue = value; + } + } + } else { + // No data exists, so set min/max to NaN + this.maxValue = Number.NaN; + this.minValue = Number.NaN; + } + }; + + /** + * Adds a new data point to the TimeSeries, preserving chronological order. + * + * @param timestamp the position, in time, of this data point + * @param value the value of this data point + * @param sumRepeatedTimeStampValues if timestamp has an exact match in the series, this flag controls + * whether it is replaced, or the values summed (defaults to false.) + */ + TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) { + // Reject NaN + if (isNaN(timestamp) || isNaN(value)){ + return + } + + var lastI = this.data.length - 1; + if (lastI >= 0) { + // Rewind until we find the place for the new data + var i = lastI; + while (true) { + var iThData = this.data[i]; + if (timestamp >= iThData[0]) { + if (timestamp === iThData[0]) { + // Update existing values in the array + if (sumRepeatedTimeStampValues) { + // Sum this value into the existing 'bucket' + iThData[1] += value; + value = iThData[1]; + } else { + // Replace the previous value + iThData[1] = value; + } + } else { + // Splice into the correct position to keep timestamps in order + this.data.splice(i + 1, 0, [timestamp, value]); + } + + break; + } + + i--; + if (i < 0) { + // This new item is the oldest data + this.data.splice(0, 0, [timestamp, value]); + + break; + } + } + } else { + // It's the first element + this.data.push([timestamp, value]); + } + + this.maxValue = isNaN(this.maxValue) ? value : Math.max(this.maxValue, value); + this.minValue = isNaN(this.minValue) ? value : Math.min(this.minValue, value); + }; + + TimeSeries.prototype.dropOldData = function(oldestValidTime, maxDataSetLength) { + // We must always keep one expired data point as we need this to draw the + // line that comes into the chart from the left, but any points prior to that can be removed. + var removeCount = 0; + while (this.data.length - removeCount >= maxDataSetLength && this.data[removeCount + 1][0] < oldestValidTime) { + removeCount++; + } + if (removeCount !== 0) { + this.data.splice(0, removeCount); + } + }; + + /** + * Initialises a new SmoothieChart. + * + * Options are optional, and should be of the form below. Just specify the values you + * need and the rest will be given sensible defaults as shown: + * + *
+   * {
+   *   minValue: undefined,                      // specify to clamp the lower y-axis to a given value
+   *   maxValue: undefined,                      // specify to clamp the upper y-axis to a given value
+   *   maxValueScale: 1,                         // allows proportional padding to be added above the chart. for 10% padding, specify 1.1.
+   *   minValueScale: 1,                         // allows proportional padding to be added below the chart. for 10% padding, specify 1.1.
+   *   yRangeFunction: undefined,                // function({min: , max: }) { return {min: , max: }; }
+   *   scaleSmoothing: 0.125,                    // controls the rate at which y-value zoom animation occurs
+   *   millisPerPixel: 20,                       // sets the speed at which the chart pans by
+   *   enableDpiScaling: true,                   // support rendering at different DPI depending on the device
+   *   yMinFormatter: function(min, precision) { // callback function that formats the min y value label
+   *     return parseFloat(min).toFixed(precision);
+   *   },
+   *   yMaxFormatter: function(max, precision) { // callback function that formats the max y value label
+   *     return parseFloat(max).toFixed(precision);
+   *   },
+   *   yIntermediateFormatter: function(intermediate, precision) { // callback function that formats the intermediate y value labels
+   *     return parseFloat(intermediate).toFixed(precision);
+   *   },
+   *   maxDataSetLength: 2,
+   *   interpolation: 'bezier'                   // one of 'bezier', 'linear', or 'step'
+   *   timestampFormatter: null,                 // optional function to format time stamps for bottom of chart
+   *                                             // you may use SmoothieChart.timeFormatter, or your own: function(date) { return ''; }
+   *   scrollBackwards: false,                   // reverse the scroll direction of the chart
+   *   horizontalLines: [],                      // [ { value: 0, color: '#ffffff', lineWidth: 1 } ]
+   *   grid:
+   *   {
+   *     fillStyle: '#000000',                   // the background colour of the chart
+   *     lineWidth: 1,                           // the pixel width of grid lines
+   *     strokeStyle: '#777777',                 // colour of grid lines
+   *     millisPerLine: 1000,                    // distance between vertical grid lines
+   *     verticalSections: 2,                    // number of vertical sections marked out by horizontal grid lines
+   *     borderVisible: true                     // whether the grid lines trace the border of the chart or not
+   *   },
+   *   labels
+   *   {
+   *     disabled: false,                        // enables/disables labels showing the min/max values
+   *     fillStyle: '#ffffff',                   // colour for text of labels,
+   *     fontSize: 15,
+   *     fontFamily: 'sans-serif',
+   *     precision: 2,
+   *     showIntermediateLabels: false,          // shows intermediate labels between min and max values along y axis
+   *     intermediateLabelSameAxis: true,
+   *   },
+   *   title
+   *   {
+   *     text: '',                               // the text to display on the left side of the chart
+   *     fillStyle: '#ffffff',                   // colour for text
+   *     fontSize: 15,
+   *     fontFamily: 'sans-serif',
+   *     verticalAlign: 'middle'                 // one of 'top', 'middle', or 'bottom'
+   *   },
+   *   tooltip: false                            // show tooltip when mouse is over the chart
+   *   tooltipLine: {                            // properties for a vertical line at the cursor position
+   *     lineWidth: 1,
+   *     strokeStyle: '#BBBBBB'
+   *   },
+   *   tooltipFormatter: SmoothieChart.tooltipFormatter, // formatter function for tooltip text
+   *   nonRealtimeData: false,                   // use time of latest data as current time
+   *   displayDataFromPercentile: 1,             // display not latest data, but data from the given percentile
+   *                                             // useful when trying to see old data saved by setting a high value for maxDataSetLength
+   *                                             // should be a value between 0 and 1
+   *   responsive: false,                        // whether the chart should adapt to the size of the canvas
+   *   limitFPS: 0                               // maximum frame rate the chart will render at, in FPS (zero means no limit)
+   * }
+   * 
+ * + * @constructor + */ + function SmoothieChart(options) { + this.options = Util.extend({}, SmoothieChart.defaultChartOptions, options); + this.seriesSet = []; + this.currentValueRange = 1; + this.currentVisMinValue = 0; + this.lastRenderTimeMillis = 0; + this.lastChartTimestamp = 0; + + this.mousemove = this.mousemove.bind(this); + this.mouseout = this.mouseout.bind(this); + } + + /** Formats the HTML string content of the tooltip. */ + SmoothieChart.tooltipFormatter = function (timestamp, data) { + var timestampFormatter = this.options.timestampFormatter || SmoothieChart.timeFormatter, + // A dummy element to hold children. Maybe there's a better way. + elements = document.createElement('div'), + label; + elements.appendChild(document.createTextNode( + timestampFormatter(new Date(timestamp)) + )); + + for (var i = 0; i < data.length; ++i) { + label = data[i].series.options.tooltipLabel || '' + if (label !== ''){ + label = label + ' '; + } + var dataEl = document.createElement('span'); + dataEl.style.color = data[i].series.options.strokeStyle; + dataEl.appendChild(document.createTextNode( + label + this.options.yMaxFormatter(data[i].value, this.options.labels.precision) + )); + elements.appendChild(document.createElement('br')); + elements.appendChild(dataEl); + } + + return elements.innerHTML; + }; + + SmoothieChart.defaultChartOptions = { + millisPerPixel: 20, + enableDpiScaling: true, + yMinFormatter: function(min, precision) { + return parseFloat(min).toFixed(precision); + }, + yMaxFormatter: function(max, precision) { + return parseFloat(max).toFixed(precision); + }, + yIntermediateFormatter: function(intermediate, precision) { + return parseFloat(intermediate).toFixed(precision); + }, + maxValueScale: 1, + minValueScale: 1, + interpolation: 'bezier', + scaleSmoothing: 0.125, + maxDataSetLength: 2, + scrollBackwards: false, + displayDataFromPercentile: 1, + grid: { + fillStyle: '#000000', + strokeStyle: '#777777', + lineWidth: 2, + millisPerLine: 1000, + verticalSections: 2, + borderVisible: true + }, + labels: { + fillStyle: '#ffffff', + disabled: false, + fontSize: 10, + fontFamily: 'monospace', + precision: 2, + showIntermediateLabels: false, + intermediateLabelSameAxis: true, + }, + title: { + text: '', + fillStyle: '#ffffff', + fontSize: 15, + fontFamily: 'monospace', + verticalAlign: 'middle' + }, + horizontalLines: [], + tooltip: false, + tooltipLine: { + lineWidth: 1, + strokeStyle: '#BBBBBB' + }, + tooltipFormatter: SmoothieChart.tooltipFormatter, + nonRealtimeData: false, + responsive: false, + limitFPS: 0 + }; + + // Based on http://inspirit.github.com/jsfeat/js/compatibility.js + SmoothieChart.AnimateCompatibility = (function() { + var requestAnimationFrame = function(callback, element) { + var requestAnimationFrame = + window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function(callback) { + return window.setTimeout(function() { + callback(Date.now()); + }, 16); + }; + return requestAnimationFrame.call(window, callback, element); + }, + cancelAnimationFrame = function(id) { + var cancelAnimationFrame = + window.cancelAnimationFrame || + function(id) { + clearTimeout(id); + }; + return cancelAnimationFrame.call(window, id); + }; + + return { + requestAnimationFrame: requestAnimationFrame, + cancelAnimationFrame: cancelAnimationFrame + }; + })(); + + SmoothieChart.defaultSeriesPresentationOptions = { + lineWidth: 1, + strokeStyle: '#ffffff', + // Maybe default to false in the next breaking version. + fillToBottom: true, + }; + + /** + * Adds a TimeSeries to this chart, with optional presentation options. + * + * Presentation options should be of the form (defaults shown): + * + *
+   * {
+   *   lineWidth: 1,
+   *   strokeStyle: '#ffffff',
+   *   fillStyle: undefined,
+   *   interpolation: undefined;
+   *   tooltipLabel: undefined,
+   *   fillToBottom: true,
+   * }
+   * 
+ */ + SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) { + this.seriesSet.push({timeSeries: timeSeries, options: Util.extend({}, SmoothieChart.defaultSeriesPresentationOptions, options)}); + if (timeSeries.options.resetBounds && timeSeries.options.resetBoundsInterval > 0) { + timeSeries.resetBoundsTimerId = setInterval( + function() { + timeSeries.resetBounds(); + }, + timeSeries.options.resetBoundsInterval + ); + } + }; + + /** + * Removes the specified TimeSeries from the chart. + */ + SmoothieChart.prototype.removeTimeSeries = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + this.seriesSet.splice(i, 1); + break; + } + } + // If a timer was operating for that timeseries, remove it + if (timeSeries.resetBoundsTimerId) { + // Stop resetting the bounds, if we were + clearInterval(timeSeries.resetBoundsTimerId); + } + }; + + /** + * Gets render options for the specified TimeSeries. + * + * As you may use a single TimeSeries in multiple charts with different formatting in each usage, + * these settings are stored in the chart. + */ + SmoothieChart.prototype.getTimeSeriesOptions = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + return this.seriesSet[i].options; + } + } + }; + + /** + * Brings the specified TimeSeries to the top of the chart. It will be rendered last. + */ + SmoothieChart.prototype.bringToFront = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + var set = this.seriesSet.splice(i, 1); + this.seriesSet.push(set[0]); + break; + } + } + }; + + /** + * Instructs the SmoothieChart to start rendering to the provided canvas, with specified delay. + * + * @param canvas the target canvas element + * @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series + * from appearing on screen, with new values flashing into view, at the expense of some latency. + */ + SmoothieChart.prototype.streamTo = function(canvas, delayMillis) { + this.canvas = canvas; + + this.clientWidth = parseInt(this.canvas.getAttribute('width')); + this.clientHeight = parseInt(this.canvas.getAttribute('height')); + + this.delay = delayMillis; + this.start(); + }; + + SmoothieChart.prototype.getTooltipEl = function () { + // Create the tool tip element lazily + if (!this.tooltipEl) { + this.tooltipEl = document.createElement('div'); + this.tooltipEl.className = 'smoothie-chart-tooltip'; + this.tooltipEl.style.pointerEvents = 'none'; + this.tooltipEl.style.position = 'absolute'; + this.tooltipEl.style.display = 'none'; + document.body.appendChild(this.tooltipEl); + } + return this.tooltipEl; + }; + + SmoothieChart.prototype.updateTooltip = function () { + if(!this.options.tooltip){ + return; + } + var el = this.getTooltipEl(); + + if (!this.mouseover || !this.options.tooltip) { + el.style.display = 'none'; + return; + } + + var time = this.lastChartTimestamp; + + // x pixel to time + var t = this.options.scrollBackwards + ? time - this.mouseX * this.options.millisPerPixel + : time - (this.clientWidth - this.mouseX) * this.options.millisPerPixel; + + var data = []; + + // For each data set... + for (var d = 0; d < this.seriesSet.length; d++) { + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + // find datapoint closest to time 't' + var closeIdx = Util.binarySearch(timeSeries.data, t); + if (closeIdx > 0 && closeIdx < timeSeries.data.length) { + data.push({ series: this.seriesSet[d], index: closeIdx, value: timeSeries.data[closeIdx][1] }); + } + } + + if (data.length) { + // TODO make `tooltipFormatter` return element(s) instead of an HTML string so it's harder for users + // to introduce an XSS. This would be a breaking change. + el.innerHTML = this.options.tooltipFormatter.call(this, t, data); + el.style.display = 'block'; + } else { + el.style.display = 'none'; + } + }; + + SmoothieChart.prototype.mousemove = function (evt) { + this.mouseover = true; + this.mouseX = evt.offsetX; + this.mouseY = evt.offsetY; + this.mousePageX = evt.pageX; + this.mousePageY = evt.pageY; + if(!this.options.tooltip){ + return; + } + var el = this.getTooltipEl(); + el.style.top = Math.round(this.mousePageY) + 'px'; + el.style.left = Math.round(this.mousePageX) + 'px'; + this.updateTooltip(); + }; + + SmoothieChart.prototype.mouseout = function () { + this.mouseover = false; + this.mouseX = this.mouseY = -1; + if (this.tooltipEl) + this.tooltipEl.style.display = 'none'; + }; + + /** + * Make sure the canvas has the optimal resolution for the device's pixel ratio. + */ + SmoothieChart.prototype.resize = function () { + var dpr = !this.options.enableDpiScaling || !window ? 1 : window.devicePixelRatio, + width, height; + if (this.options.responsive) { + // Newer behaviour: Use the canvas's size in the layout, and set the internal + // resolution according to that size and the device pixel ratio (eg: high DPI) + width = this.canvas.offsetWidth; + height = this.canvas.offsetHeight; + + if (width !== this.lastWidth) { + this.lastWidth = width; + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } + if (height !== this.lastHeight) { + this.lastHeight = height; + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } + + this.clientWidth = width; + this.clientHeight = height; + } else { + width = parseInt(this.canvas.getAttribute('width')); + height = parseInt(this.canvas.getAttribute('height')); + + if (dpr !== 1) { + // Older behaviour: use the canvas's inner dimensions and scale the element's size + // according to that size and the device pixel ratio (eg: high DPI) + + if (Math.floor(this.clientWidth * dpr) !== width) { + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.style.width = width + 'px'; + this.clientWidth = width; + this.canvas.getContext('2d').scale(dpr, dpr); + } + + if (Math.floor(this.clientHeight * dpr) !== height) { + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.style.height = height + 'px'; + this.clientHeight = height; + this.canvas.getContext('2d').scale(dpr, dpr); + } + } else { + this.clientWidth = width; + this.clientHeight = height; + } + } + }; + + /** + * Starts the animation of this chart. + */ + SmoothieChart.prototype.start = function() { + if (this.frame) { + // We're already running, so just return + return; + } + + this.canvas.addEventListener('mousemove', this.mousemove); + this.canvas.addEventListener('mouseout', this.mouseout); + + // Renders a frame, and queues the next frame for later rendering + var animate = function() { + this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() { + if(this.options.nonRealtimeData){ + var dateZero = new Date(0); + // find the data point with the latest timestamp + var maxTimeStamp = this.seriesSet.reduce(function(max, series){ + var dataSet = series.timeSeries.data; + var indexToCheck = Math.round(this.options.displayDataFromPercentile * dataSet.length) - 1; + indexToCheck = indexToCheck >= 0 ? indexToCheck : 0; + indexToCheck = indexToCheck <= dataSet.length -1 ? indexToCheck : dataSet.length -1; + if(dataSet && dataSet.length > 0) + { + // timestamp corresponds to element 0 of the data point + var lastDataTimeStamp = dataSet[indexToCheck][0]; + max = max > lastDataTimeStamp ? max : lastDataTimeStamp; + } + return max; + }.bind(this), dateZero); + // use the max timestamp as current time + this.render(this.canvas, maxTimeStamp > dateZero ? maxTimeStamp : null); + } else { + this.render(); + } + animate(); + }.bind(this)); + }.bind(this); + + animate(); + }; + + /** + * Stops the animation of this chart. + */ + SmoothieChart.prototype.stop = function() { + if (this.frame) { + SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame); + delete this.frame; + this.canvas.removeEventListener('mousemove', this.mousemove); + this.canvas.removeEventListener('mouseout', this.mouseout); + } + }; + + SmoothieChart.prototype.updateValueRange = function() { + // Calculate the current scale of the chart, from all time series. + var chartOptions = this.options, + chartMaxValue = Number.NaN, + chartMinValue = Number.NaN; + + for (var d = 0; d < this.seriesSet.length; d++) { + // TODO(ndunn): We could calculate / track these values as they stream in. + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + if (!isNaN(timeSeries.maxValue)) { + chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue; + } + + if (!isNaN(timeSeries.minValue)) { + chartMinValue = !isNaN(chartMinValue) ? Math.min(chartMinValue, timeSeries.minValue) : timeSeries.minValue; + } + } + + // Scale the chartMaxValue to add padding at the top if required + if (chartOptions.maxValue != null) { + chartMaxValue = chartOptions.maxValue; + } else { + chartMaxValue *= chartOptions.maxValueScale; + } + + // Set the minimum if we've specified one + if (chartOptions.minValue != null) { + chartMinValue = chartOptions.minValue; + } else { + chartMinValue -= Math.abs(chartMinValue * chartOptions.minValueScale - chartMinValue); + } + + // If a custom range function is set, call it + if (this.options.yRangeFunction) { + var range = this.options.yRangeFunction({min: chartMinValue, max: chartMaxValue}); + chartMinValue = range.min; + chartMaxValue = range.max; + } + + if (!isNaN(chartMaxValue) && !isNaN(chartMinValue)) { + var targetValueRange = chartMaxValue - chartMinValue; + var valueRangeDiff = (targetValueRange - this.currentValueRange); + var minValueDiff = (chartMinValue - this.currentVisMinValue); + this.isAnimatingScale = Math.abs(valueRangeDiff) > 0.1 || Math.abs(minValueDiff) > 0.1; + this.currentValueRange += chartOptions.scaleSmoothing * valueRangeDiff; + this.currentVisMinValue += chartOptions.scaleSmoothing * minValueDiff; + } + + this.valueRange = { min: chartMinValue, max: chartMaxValue }; + }; + + SmoothieChart.prototype.render = function(canvas, time) { + var chartOptions = this.options, + nowMillis = Date.now(); + + // Respect any frame rate limit. + if (chartOptions.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/chartOptions.limitFPS)) + return; + + time = (time || nowMillis) - (this.delay || 0); + + // Round time down to pixel granularity, so that pixel sample values remain the same, + // just shifted 1px to the left, so motion appears smoother. + time -= time % chartOptions.millisPerPixel; + + if (!this.isAnimatingScale) { + // We're not animating. We can use the last render time and the scroll speed to work out whether + // we actually need to paint anything yet. If not, we can return immediately. + var sameTime = this.lastChartTimestamp === time; + if (sameTime) { + // Render at least every 1/6th of a second. The canvas may be resized, which there is + // no reliable way to detect. + var needToRenderInCaseCanvasResized = nowMillis - this.lastRenderTimeMillis > 1000/6; + if (!needToRenderInCaseCanvasResized) { + return; + } + } + } + + this.lastRenderTimeMillis = nowMillis; + this.lastChartTimestamp = time; + + this.resize(); + + canvas = canvas || this.canvas; + var context = canvas.getContext('2d'), + // Using `this.clientWidth` instead of `canvas.clientWidth` because the latter is slow. + dimensions = { top: 0, left: 0, width: this.clientWidth, height: this.clientHeight }, + // Calculate the threshold time for the oldest data points. + oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel), + valueToYPosition = function(value, lineWidth) { + var offset = value - this.currentVisMinValue, + unsnapped = this.currentValueRange === 0 + ? dimensions.height + : dimensions.height * (1 - offset / this.currentValueRange); + return Util.pixelSnap(unsnapped, lineWidth); + }.bind(this), + timeToXPosition = function(t, lineWidth) { + // Why not write it as `(time - t) / chartOptions.millisPerPixel`: + // If a datapoint's `t` is very close or is at the center of a pixel, that expression, + // due to floating point error, may take value whose `% 1` sometimes is very close to + // 0 and sometimes is close to 1, depending on the value of render time (`time`), + // which would make `pixelSnap` snap it sometimes to the right and sometimes to the left, + // which would look like it's jumping. + // You can try the default examples, with `millisPerPixel = 100 / 3` and + // `grid.lineWidth = 1`. The grid would jump. + // Writing it this way seems to avoid such inconsistency because in the above example + // `offset` is (almost?) always a whole number. + // TODO Maybe there's a more elegant (and reliable?) way. + var offset = time / chartOptions.millisPerPixel - t / chartOptions.millisPerPixel; + var unsnapped = chartOptions.scrollBackwards + ? offset + : dimensions.width - offset; + return Util.pixelSnap(unsnapped, lineWidth); + }; + + this.updateValueRange(); + + context.font = chartOptions.labels.fontSize + 'px ' + chartOptions.labels.fontFamily; + + // Save the state of the canvas context, any transformations applied in this method + // will get removed from the stack at the end of this method when .restore() is called. + context.save(); + + // Move the origin. + context.translate(dimensions.left, dimensions.top); + + // Create a clipped rectangle - anything we draw will be constrained to this rectangle. + // This prevents the occasional pixels from curves near the edges overrunning and creating + // screen cheese (that phrase should need no explanation). + context.beginPath(); + context.rect(0, 0, dimensions.width, dimensions.height); + context.clip(); + + // Clear the working area. + context.save(); + context.fillStyle = chartOptions.grid.fillStyle; + context.clearRect(0, 0, dimensions.width, dimensions.height); + context.fillRect(0, 0, dimensions.width, dimensions.height); + context.restore(); + + // Grid lines... + context.save(); + context.lineWidth = chartOptions.grid.lineWidth; + context.strokeStyle = chartOptions.grid.strokeStyle; + // Vertical (time) dividers. + if (chartOptions.grid.millisPerLine > 0) { + context.beginPath(); + for (var t = time - (time % chartOptions.grid.millisPerLine); + t >= oldestValidTime; + t -= chartOptions.grid.millisPerLine) { + var gx = timeToXPosition(t, chartOptions.grid.lineWidth); + context.moveTo(gx, 0); + context.lineTo(gx, dimensions.height); + } + context.stroke(); + } + + // Horizontal (value) dividers. + for (var v = 1; v < chartOptions.grid.verticalSections; v++) { + var gy = Util.pixelSnap(v * dimensions.height / chartOptions.grid.verticalSections, chartOptions.grid.lineWidth); + context.beginPath(); + context.moveTo(0, gy); + context.lineTo(dimensions.width, gy); + context.stroke(); + } + // Bounding rectangle. + if (chartOptions.grid.borderVisible) { + context.strokeRect(0, 0, dimensions.width, dimensions.height); + } + context.restore(); + + // Draw any horizontal lines... + if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) { + for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) { + var line = chartOptions.horizontalLines[hl], + lineWidth = line.lineWidth || 1, + hly = valueToYPosition(line.value, lineWidth); + context.strokeStyle = line.color || '#ffffff'; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(0, hly); + context.lineTo(dimensions.width, hly); + context.stroke(); + } + } + + // For each data set... + for (var d = 0; d < this.seriesSet.length; d++) { + var timeSeries = this.seriesSet[d].timeSeries, + dataSet = timeSeries.data; + + // Delete old data that's moved off the left of the chart. + timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength); + if (dataSet.length <= 1 || timeSeries.disabled) { + continue; + } + context.save(); + + var seriesOptions = this.seriesSet[d].options, + // Keep in mind that `context.lineWidth = 0` doesn't actually set it to `0`. + drawStroke = seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none', + lineWidthMaybeZero = drawStroke ? seriesOptions.lineWidth : 0; + + // Draw the line... + context.beginPath(); + // Retain lastX, lastY for calculating the control points of bezier curves. + var firstX = timeToXPosition(dataSet[0][0], lineWidthMaybeZero), + firstY = valueToYPosition(dataSet[0][1], lineWidthMaybeZero), + lastX = firstX, + lastY = firstY, + draw; + context.moveTo(firstX, firstY); + switch (seriesOptions.interpolation || chartOptions.interpolation) { + case "linear": + case "line": { + draw = function(x, y, lastX, lastY) { + context.lineTo(x,y); + } + break; + } + case "bezier": + default: { + // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves + // + // Assuming A was the last point in the line plotted and B is the new point, + // we draw a curve with control points P and Q as below. + // + // A---P + // | + // | + // | + // Q---B + // + // Importantly, A and P are at the same y coordinate, as are B and Q. This is + // so adjacent curves appear to flow as one. + // + draw = function(x, y, lastX, lastY) { + context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop + Math.round((lastX + x) / 2), lastY, // controlPoint1 (P) + Math.round((lastX + x)) / 2, y, // controlPoint2 (Q) + x, y); // endPoint (B) + } + break; + } + case "step": { + draw = function(x, y, lastX, lastY) { + context.lineTo(x,lastY); + context.lineTo(x,y); + } + break; + } + } + + for (var i = 1; i < dataSet.length; i++) { + var iThData = dataSet[i], + x = timeToXPosition(iThData[0], lineWidthMaybeZero), + y = valueToYPosition(iThData[1], lineWidthMaybeZero); + draw(x, y, lastX, lastY); + lastX = x; lastY = y; + } + + if (drawStroke) { + context.lineWidth = seriesOptions.lineWidth; + context.strokeStyle = seriesOptions.strokeStyle; + context.stroke(); + } + + if (seriesOptions.fillStyle) { + // Close up the fill region. + var fillEndY = seriesOptions.fillToBottom + ? dimensions.height + lineWidthMaybeZero + 1 + : valueToYPosition(0, 0); + context.lineTo(lastX, fillEndY); + context.lineTo(firstX, fillEndY); + + context.fillStyle = seriesOptions.fillStyle; + context.fill(); + } + + context.restore(); + } + + if (chartOptions.tooltip && this.mouseX >= 0) { + // Draw vertical bar to show tooltip position + context.lineWidth = chartOptions.tooltipLine.lineWidth; + context.strokeStyle = chartOptions.tooltipLine.strokeStyle; + context.beginPath(); + context.moveTo(this.mouseX, 0); + context.lineTo(this.mouseX, dimensions.height); + context.stroke(); + } + this.updateTooltip(); + + var labelsOptions = chartOptions.labels; + // Draw the axis values on the chart. + if (!labelsOptions.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) { + var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, labelsOptions.precision), + minValueString = chartOptions.yMinFormatter(this.valueRange.min, labelsOptions.precision), + maxLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2, + minLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(minValueString).width - 2; + context.fillStyle = labelsOptions.fillStyle; + context.fillText(maxValueString, maxLabelPos, labelsOptions.fontSize); + context.fillText(minValueString, minLabelPos, dimensions.height - 2); + } + + // Display intermediate y axis labels along y-axis to the left of the chart + if ( labelsOptions.showIntermediateLabels + && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max) + && chartOptions.grid.verticalSections > 0) { + // show a label above every vertical section divider + var step = (this.valueRange.max - this.valueRange.min) / chartOptions.grid.verticalSections; + var stepPixels = dimensions.height / chartOptions.grid.verticalSections; + for (var v = 1; v < chartOptions.grid.verticalSections; v++) { + var gy = dimensions.height - Math.round(v * stepPixels), + yValue = chartOptions.yIntermediateFormatter(this.valueRange.min + (v * step), labelsOptions.precision), + //left of right axis? + intermediateLabelPos = + labelsOptions.intermediateLabelSameAxis + ? (chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(yValue).width - 2) + : (chartOptions.scrollBackwards ? dimensions.width - context.measureText(yValue).width - 2 : 0); + + context.fillText(yValue, intermediateLabelPos, gy - chartOptions.grid.lineWidth); + } + } + + // Display timestamps along x-axis at the bottom of the chart. + if (chartOptions.timestampFormatter && chartOptions.grid.millisPerLine > 0) { + var textUntilX = chartOptions.scrollBackwards + ? context.measureText(minValueString).width + : dimensions.width - context.measureText(minValueString).width + 4; + for (var t = time - (time % chartOptions.grid.millisPerLine); + t >= oldestValidTime; + t -= chartOptions.grid.millisPerLine) { + var gx = timeToXPosition(t, 0); + // Only draw the timestamp if it won't overlap with the previously drawn one. + if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX)) { + // Formats the timestamp based on user specified formatting function + // SmoothieChart.timeFormatter function above is one such formatting option + var tx = new Date(t), + ts = chartOptions.timestampFormatter(tx), + tsWidth = context.measureText(ts).width; + + textUntilX = chartOptions.scrollBackwards + ? gx + tsWidth + 2 + : gx - tsWidth - 2; + + context.fillStyle = chartOptions.labels.fillStyle; + if(chartOptions.scrollBackwards) { + context.fillText(ts, gx, dimensions.height - 2); + } else { + context.fillText(ts, gx - tsWidth, dimensions.height - 2); + } + } + } + } + + // Display title. + if (chartOptions.title.text !== '') { + context.font = chartOptions.title.fontSize + 'px ' + chartOptions.title.fontFamily; + var titleXPos = chartOptions.scrollBackwards ? dimensions.width - context.measureText(chartOptions.title.text).width - 2 : 2; + if (chartOptions.title.verticalAlign == 'bottom') { + context.textBaseline = 'bottom'; + var titleYPos = dimensions.height; + } else if (chartOptions.title.verticalAlign == 'middle') { + context.textBaseline = 'middle'; + var titleYPos = dimensions.height / 2; + } else { + context.textBaseline = 'top'; + var titleYPos = 0; + } + context.fillStyle = chartOptions.title.fillStyle; + context.fillText(chartOptions.title.text, titleXPos, titleYPos); + } + + context.restore(); // See .save() above. + }; + + // Sample timestamp formatting function + SmoothieChart.timeFormatter = function(date) { + function pad2(number) { return (number < 10 ? '0' : '') + number } + return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds()); + }; + + exports.TimeSeries = TimeSeries; + exports.SmoothieChart = SmoothieChart; + +})(typeof exports === 'undefined' ? this : exports); + From c40ce5be5a3f6e456d7e0372446c4a773c078f48 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 16:08:30 -0400 Subject: [PATCH 09/59] Enhance DashStats page: make canvas elements responsive and enable responsive charts for better display on various screen sizes. --- emhttp/plugins/dynamix/DashStats.page | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index 22cdff963..c02afdf0e 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -412,7 +412,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout ?> - + @@ -584,7 +584,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout ?> - + @@ -1441,6 +1441,7 @@ var cpuchart = new SmoothieChart({ millisPerPixel: 100, minValue: 0, maxValue: 100, + responsive: true, grid: { strokeStyle: '', fillStyle: 'transparent', @@ -1462,6 +1463,7 @@ var cpuchart = new SmoothieChart({ var netchart = new SmoothieChart({ millisPerPixel: 100, minValue: 0, + responsive: true, grid: { strokeStyle: '', fillStyle: 'transparent', From 55007450065ed57f69dc4017d098b33bf1820717 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 16:16:19 -0400 Subject: [PATCH 10/59] fix: cut off when CPU is at zero --- emhttp/plugins/dynamix/DashStats.page | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index c02afdf0e..c03850b88 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -1439,7 +1439,7 @@ var recover = null; // SmoothieCharts initialization var cpuchart = new SmoothieChart({ millisPerPixel: 100, - minValue: 0, + minValue: -0.5, maxValue: 100, responsive: true, grid: { @@ -1456,7 +1456,13 @@ var cpuchart = new SmoothieChart({ }, timestampFormatter: function(date) { return ''; }, yRangeFunction: function(range) { - return { min: 0, max: 100 }; + return { min: -0.5, max: 100 }; + }, + yMinFormatter: function(value) { + return Math.max(0, Math.floor(value)); + }, + yMaxFormatter: function(value) { + return Math.floor(value); } }); From 05f44161e4413897d86fd7a195e5d6b500ae8537 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 16:30:40 -0400 Subject: [PATCH 11/59] fix: optimize readmorejs with more efficient rendering --- emhttp/plugins/dynamix/javascript/dynamix.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/javascript/dynamix.js b/emhttp/plugins/dynamix/javascript/dynamix.js index 0293f6147..57409d8c0 100644 --- a/emhttp/plugins/dynamix/javascript/dynamix.js +++ b/emhttp/plugins/dynamix/javascript/dynamix.js @@ -54,8 +54,8 @@ Q.find=(function(){var aP=/((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^[\]]*\]|['"][ /* Modified by Andrew Zawadzki - Changed variable code to be codeAZ due to conflict with const code in vue */ shortcut={all_shortcuts:{},add:function(l,p,i){var e={type:"keydown",propagate:!1,disable_in_input:!1,target:document,keycode:!1};if(i)for(var t in e)void 0===i[t]&&(i[t]=e[t]);else i=e;var a=i.target;"string"==typeof i.target&&(a=document.getElementById(i.target));l=l.toLowerCase();function r(e){var t;if(e=e||window.event,!i.disable_in_input||(e.target?t=e.target:e.srcElement&&(t=e.srcElement),3==t.nodeType&&(t=t.parentNode),"INPUT"!=t.tagName&&"TEXTAREA"!=t.tagName)){e.keyCode?codeAZ=e.keyCode:e.which&&(codeAZ=e.which);var a=String.fromCharCode(codeAZ).toLowerCase();188==codeAZ&&(a=","),190==codeAZ&&(a=".");var r=l.split("+"),n=0,s={"`":"~",1:"!",2:"@",3:"#",4:"$",5:"%",6:"^",7:"&",8:"*",9:"(",0:")","-":"_","=":"+",";":":","'":'"',",":"<",".":">","/":"?","\\":"|"},o={esc:27,escape:27,tab:9,space:32,return:13,enter:13,backspace:8,scrolllock:145,scroll_lock:145,scroll:145,capslock:20,caps_lock:20,caps:20,numlock:144,num_lock:144,num:144,pause:19,break:19,insert:45,home:36,delete:46,end:35,pageup:33,page_up:33,pu:33,pagedown:34,page_down:34,pd:34,left:37,up:38,right:39,down:40,f1:112,f2:113,f3:114,f4:115,f5:116,f6:117,f7:118,f8:119,f9:120,f10:121,f11:122,f12:123},d={shift:{wanted:!1,pressed:!1},ctrl:{wanted:!1,pressed:!1},alt:{wanted:!1,pressed:!1},meta:{wanted:!1,pressed:!1}};e.ctrlKey&&(d.ctrl.pressed=!0),e.shiftKey&&(d.shift.pressed=!0),e.altKey&&(d.alt.pressed=!0),e.metaKey&&(d.meta.pressed=!0);for(var c=0;k=r[c],cRead More',lessLink:'Close',embedCSS:!0,sectionCSS:"display: block; width: 100%;",startOpen:!1,expandedClass:"readmore-js-expanded",collapsedClass:"readmore-js-collapsed",beforeToggle:function(){},afterToggle:function(){}},k=!1;g.prototype={init:function(){var b=this;c(this.element).each(function(){var a=c(this),d=a.css("max-height").replace(/[^-\d\.]/g,"")>a.data("max-height")?a.css("max-height").replace(/[^-\d\.]/g,""):a.data("max-height"),e=a.data("height-margin");"none"!=a.css("max-height")&&a.css("max-height","none");b.setBoxHeight(a);if(a.outerHeight(!0)<=d+e)return!0;a.addClass("readmore-js-section "+b.options.collapsedClass).data("collapsedHeight",d);a.after(c(b.options.startOpen?b.options.lessLink:b.options.moreLink).on("click",function(c){b.toggleSlider(this,a,c)}).addClass("readmore-js-toggle"));b.options.startOpen||a.css({height:d})});c(window).on("resize",function(a){b.resizeBoxes()})},toggleSlider:function(b,a,d){d.preventDefault();var e=this;d=newLink=sectionClass="";var f=!1;d=c(a).data("collapsedHeight");c(a).height()<=d?(d=c(a).data("expandedHeight")+"px",newLink="lessLink",f=!0,sectionClass=e.options.expandedClass):(newLink="moreLink",sectionClass=e.options.collapsedClass);e.options.beforeToggle(b,a,f);c(a).animate({height:d},{duration:e.options.speed,complete:function(){e.options.afterToggle(b,a,f);c(b).replaceWith(c(e.options[newLink]).on("click",function(b){e.toggleSlider(this,a,b)}).addClass("readmore-js-toggle"));c(this).removeClass(e.options.collapsedClass+" "+e.options.expandedClass).addClass(sectionClass)}})},setBoxHeight:function(b){var a=b.clone().css({height:"auto",width:b.width(),overflow:"hidden"}).insertAfter(b),c=a.outerHeight(!0);a.remove();b.data("expandedHeight",c)},resizeBoxes:function(){var b=this;c(".readmore-js-section").each(function(){var a=c(this);b.setBoxHeight(a);(a.height()>a.data("expandedHeight")||a.hasClass(b.options.expandedClass)&&a.height()Read More',lessLink:'Close',embedCSS:!0,sectionCSS:"display: block; width: 100%;",startOpen:!1,expandedClass:"readmore-js-expanded",collapsedClass:"readmore-js-collapsed",beforeToggle:function(){},afterToggle:function(){}},k=!1,resizeTimer=null,measuredHeights=new WeakMap();g.prototype={init:function(){var b=this;c(this.element).each(function(){var a=c(this),d=a.css("max-height").replace(/[^-\d\.]/g,"")>a.data("max-height")?a.css("max-height").replace(/[^-\d\.]/g,""):a.data("max-height"),e=a.data("height-margin");"none"!=a.css("max-height")&&a.css("max-height","none");var expandedHeight=b.getBoxHeight(a);if(a.outerHeight(!0)<=d+e)return!0;a.addClass("readmore-js-section "+b.options.collapsedClass).data("collapsedHeight",d).data("expandedHeight",expandedHeight);var toggle=c(b.options.startOpen?b.options.lessLink:b.options.moreLink).on("click",function(c){b.toggleSlider(this,a,c)}).addClass("readmore-js-toggle");a.after(toggle);b.options.startOpen||a.css({height:d})});c(window).on("resize.readmore",function(a){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){b.resizeBoxes()},250)})},toggleSlider:function(b,a,d){d.preventDefault();var e=this;var collapsed=a.hasClass(e.options.collapsedClass);var newHeight=collapsed?a.data("expandedHeight"):a.data("collapsedHeight");var newLink=collapsed?e.options.lessLink:e.options.moreLink;var sectionClass=collapsed?e.options.expandedClass:e.options.collapsedClass;e.options.beforeToggle(b,a,collapsed);c(a).animate({height:newHeight},{duration:e.options.speed,complete:function(){e.options.afterToggle(b,a,collapsed);var newToggle=c(newLink).on("click",function(c){e.toggleSlider(this,a,c)}).addClass("readmore-js-toggle");c(b).replaceWith(newToggle);c(this).removeClass(e.options.collapsedClass+" "+e.options.expandedClass).addClass(sectionClass)}})},getBoxHeight:function(b){var cached=measuredHeights.get(b[0]);if(cached)return cached;var origHeight=b.css("height");b.css({height:"auto"});var expandedHeight=b.outerHeight(!0);b.css({height:origHeight});measuredHeights.set(b[0],expandedHeight);return expandedHeight},resizeBoxes:function(){var b=this;measuredHeights=new WeakMap();c(".readmore-js-section").each(function(){var a=c(this);var expandedHeight=b.getBoxHeight(a);a.data("expandedHeight",expandedHeight);if(a.hasClass(b.options.expandedClass)){a.css("height",expandedHeight+"px")}})},destroy:function(){var b=this;c(window).off("resize.readmore");c(this.element).each(function(){var a=c(this);a.removeClass("readmore-js-section "+b.options.collapsedClass+" "+b.options.expandedClass).css({"max-height":"",height:"auto"}).next(".readmore-js-toggle").remove();a.removeData();measuredHeights.delete(a[0])})}};c.fn[f]=function(b){var a=arguments;if(void 0===b||"object"===typeof b)return this.each(function(){if(c.data(this,"plugin_"+f)){var a=c.data(this,"plugin_"+f);a.destroy.apply(a)}c.data(this,"plugin_"+f,new g(this,b))});if("string"===typeof b&&"_"!==b[0]&&"init"!==b)return this.each(function(){var d=c.data(this,"plugin_"+f);d instanceof g&&"function"===typeof d[b]&&d[b].apply(d,Array.prototype.slice.call(a,1))})}})(jQuery); /** * Simple, lightweight, usable local autocomplete library for modern browsers * Because there weren’t enough autocomplete scripts in the world? Because I’m completely insane and have NIH syndrome? Probably both. :P From 34d60fd3a0394406fc8f094fc438b0a2090a4b68 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 16:56:12 -0400 Subject: [PATCH 12/59] refactor: improve chart responsiveness and initialization in DashStats page --- emhttp/plugins/dynamix/DashStats.page | 39 +++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index c03850b88..267a170c7 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -1427,8 +1427,8 @@ var rxd = []; var txd = []; var cputime = 0; var nettime = 0; -var cpuline = cookie.cpuline||30; -var netline = cookie.netline||30; +var cpuline = parseInt(cookie.cpuline)||30; +var netline = parseInt(cookie.netline)||30; var update2 = true; var box = null; var startup = true; @@ -1436,9 +1436,16 @@ var stopgap = ' var recall = null; var recover = null; +// Helper function to calculate millisPerPixel based on container width +function getMillisPerPixel(timeInSeconds, containerId) { + var container = document.getElementById(containerId); + var width = (container && container.offsetWidth > 0) ? container.offsetWidth : 600; // fallback to 600 if not found or zero width + return (timeInSeconds * 1000) / width; +} + // SmoothieCharts initialization var cpuchart = new SmoothieChart({ - millisPerPixel: 100, + millisPerPixel: (cpuline * 1000) / 600, // Safe initial value minValue: -0.5, maxValue: 100, responsive: true, @@ -1467,7 +1474,7 @@ var cpuchart = new SmoothieChart({ }); var netchart = new SmoothieChart({ - millisPerPixel: 100, + millisPerPixel: (netline * 1000) / 600, // Safe initial value minValue: 0, responsive: true, grid: { @@ -1617,7 +1624,9 @@ let viewportResizeTimeout; window.addEventListener('resize', function(){ clearTimeout(viewportResizeTimeout); viewportResizeTimeout = setTimeout(function(){ - // SmoothieCharts handles resize automatically + // Update millisPerPixel based on new container sizes + cpuchart.options.millisPerPixel = getMillisPerPixel(cpuline, 'cpuchart'); + netchart.options.millisPerPixel = getMillisPerPixel(netline, 'netchart'); }, 250); // Wait 250ms after resize stops before executing }); @@ -1861,21 +1870,23 @@ function changeView(item) { } function changeCPUline(val) { - cpuline = val; + cpuline = parseInt(val); if (val==30) delete cookie.cpuline; else cookie.cpuline = val; saveCookie(); // Update chart range - // Update SmoothieChart time window - cpuchart.options.millisPerPixel = (cpuline * 1000) / 600; + // Update SmoothieChart time window based on actual chart width + cpuchart.options.millisPerPixel = getMillisPerPixel(cpuline, 'cpuchart'); + // No need to stop and restart, just update the option } function changeNetline(val) { - netline = val; + netline = parseInt(val); if (val==30) delete cookie.netline; else cookie.netline = val; saveCookie(); // Update chart range - // Update SmoothieChart time window - netchart.options.millisPerPixel = (netline * 1000) / 600; + // Update SmoothieChart time window based on actual chart width + netchart.options.millisPerPixel = getMillisPerPixel(netline, 'netchart'); + // No need to stop and restart, just update the option } function smartMenu(table) { @@ -2611,6 +2622,12 @@ $(function() { cpuchart.streamTo(document.getElementById('cpuchart'), 1000); netchart.streamTo(document.getElementById('netchart'), 1000); + // Set millisPerPixel after DOM is ready and charts are attached + setTimeout(function() { + cpuchart.options.millisPerPixel = getMillisPerPixel(cpuline, 'cpuchart'); + netchart.options.millisPerPixel = getMillisPerPixel(netline, 'netchart'); + }, 100); + addProperties(); dropdown('enter_share'); From 84d1bad01dc968fd13fb774ebc5cc292c7e5817e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 17:10:59 -0400 Subject: [PATCH 13/59] refactor: optimize CPU load updates in DashStats page to reduce unnecessary DOM manipulations --- emhttp/plugins/dynamix/DashStats.page | 112 +++++++++++++++++--------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index 267a170c7..7fadf40ca 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -1689,6 +1689,14 @@ function addChartNet(rx, tx) { txTimeSeries.append(now, tx / 1000); } +// Cache for last values to avoid unnecessary DOM updates +var lastCpuValues = { + load: -1, + color: '', + fontColor: '', + coreValues: {} +}; + function updateCPUBarCharts() { if (!isPageVisible()) { return; @@ -1703,54 +1711,84 @@ function updateCPUBarCharts() { const cpuLoad = customData.cpuLoad; const critical = ; const warning = ; - - // Cache DOM elements and calculations for overall CPU load - const cpuLoadText = cpuLoad + '%'; - const cpuLoadColor = setColor(cpuLoad, critical, warning); - const cpuLoadFontColor = fontColor(cpuLoad, critical, warning); - - // Batch DOM updates for overall CPU load - const $cpuElements = $('.cpu_, .cpu'); - const $cpuAliveElements = $('#cpu_, #cpu'); - - $cpuElements.text(cpuLoadText).css({'color': cpuLoadFontColor}); - $cpuAliveElements.alive(cpuLoadText, cpuLoadColor); + + // Only update DOM if values have changed + if (cpuLoad !== lastCpuValues.load) { + const cpuLoadText = cpuLoad + '%'; + const cpuLoadColor = setColor(cpuLoad, critical, warning); + const cpuLoadFontColor = fontColor(cpuLoad, critical, warning); + + // Batch DOM updates for overall CPU load + const $cpuElements = $('.cpu_, .cpu'); + const $cpuAliveElements = $('#cpu_, #cpu'); + + $cpuElements.text(cpuLoadText).css({'color': cpuLoadFontColor}); + + // Only call alive() if color actually changed + if (cpuLoadColor !== lastCpuValues.color) { + $cpuAliveElements.alive(cpuLoadText, cpuLoadColor); + lastCpuValues.color = cpuLoadColor; + } + + lastCpuValues.load = cpuLoad; + lastCpuValues.fontColor = cpuLoadFontColor; + } // Update individual CPU cores if they are visible if (customData.coresVisible) { const cpus = customData.cpuData.cpus; - + // Batch DOM updates for CPU cores const cpuCoreUpdates = []; const cpuAliveUpdates = []; - + cpus.forEach((cpuCore, index) => { const coreLoad = Math.round(cpuCore.percentTotal); - const coreLoadText = coreLoad + '%'; - const coreColor = setColor(coreLoad, critical, warning); - const coreFontColor = fontColor(coreLoad, critical, warning); - - cpuCoreUpdates.push({ - selector: '.cpu' + index, - text: coreLoadText, - color: coreFontColor + + // Only process if value changed + if (!lastCpuValues.coreValues[index] || lastCpuValues.coreValues[index].load !== coreLoad) { + const coreLoadText = coreLoad + '%'; + const coreColor = setColor(coreLoad, critical, warning); + const coreFontColor = fontColor(coreLoad, critical, warning); + + cpuCoreUpdates.push({ + selector: '.cpu' + index, + text: coreLoadText, + color: coreFontColor + }); + + // Only update alive() if color changed + const lastCore = lastCpuValues.coreValues[index] || {}; + if (coreColor !== lastCore.color) { + cpuAliveUpdates.push({ + selector: '#cpu' + index, + text: coreLoadText, + color: coreColor + }); + lastCore.color = coreColor; + } + + // Update cache + lastCpuValues.coreValues[index] = { + load: coreLoad, + color: coreColor, + fontColor: coreFontColor + }; + } + }); + + // Apply batch updates only for changed values + if (cpuCoreUpdates.length > 0) { + cpuCoreUpdates.forEach(update => { + $(update.selector).text(update.text).css({'color': update.color}); }); - - cpuAliveUpdates.push({ - selector: '#cpu' + index, - text: coreLoadText, - color: coreColor + } + + if (cpuAliveUpdates.length > 0) { + cpuAliveUpdates.forEach(update => { + $(update.selector).alive(update.text, update.color); }); - }); - - // Apply all CPU core updates in batches - cpuCoreUpdates.forEach(update => { - $(update.selector).text(update.text).css({'color': update.color}); - }); - - cpuAliveUpdates.forEach(update => { - $(update.selector).alive(update.text, update.color); - }); + } } } From dcc5506e9f6feacebee9cb5aa53e248572f1e64c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 17:13:24 -0400 Subject: [PATCH 14/59] refactor: enhance network value updates and optimize DOM manipulation in DashStats page --- emhttp/plugins/dynamix/DashStats.page | 45 ++++++++++++++++++++------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index 7fadf40ca..e3c50d8bd 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -1685,8 +1685,8 @@ function addChartCpu(load) { function addChartNet(rx, tx) { // Add data points to SmoothieCharts TimeSeries (convert bps to kbps) var now = new Date().getTime(); - rxTimeSeries.append(now, rx / 1000); - txTimeSeries.append(now, tx / 1000); + rxTimeSeries.append(now, Math.floor(rx / 1000)); + txTimeSeries.append(now, Math.floor(tx / 1000)); } // Cache for last values to avoid unnecessary DOM updates @@ -1743,7 +1743,7 @@ function updateCPUBarCharts() { const cpuAliveUpdates = []; cpus.forEach((cpuCore, index) => { - const coreLoad = Math.round(cpuCore.percentTotal); + const coreLoad = Math.floor(cpuCore.percentTotal); // Only process if value changed if (!lastCpuValues.coreValues[index] || lastCpuValues.coreValues[index].load !== coreLoad) { @@ -2590,18 +2590,41 @@ dashboard.on('message',function(msg,meta) { // rx & tx speeds for (let i=0,port; port=get.port[i]; i++) { if (port[0] == port_select) { - $('#inbound').text(port[1]); - $('#outbound').text(port[2]); + // Cache for network values to avoid unnecessary DOM updates + if (!window.lastNetValues) window.lastNetValues = { inbound: '', outbound: '' }; + + // Only update DOM if values changed + if (port[1] !== window.lastNetValues.inbound) { + $('#inbound').text(port[1]); + window.lastNetValues.inbound = port[1]; + } + if (port[2] !== window.lastNetValues.outbound) { + $('#outbound').text(port[2]); + window.lastNetValues.outbound = port[2]; + } + // update the netchart but only send to ApexCharts if the chart is visible addChartNet(port[3], port[4]); - + break; } } - // port counters - for (let k=0; k < get.mode.length; k++) $('#main'+k).html(get.mode[k]); - for (let k=0; k < get.rxtx.length; k++) $('#port'+k).html(get.rxtx[k]); - for (let k=0; k < get.stat.length; k++) $('#link'+k).html(get.stat[k]); + // port counters - batch check for changes + for (let k=0; k < get.mode.length; k++) { + const $elem = $('#main'+k); + const newVal = get.mode[k]; + if ($elem.html() !== newVal) $elem.html(newVal); + } + for (let k=0; k < get.rxtx.length; k++) { + const $elem = $('#port'+k); + const newVal = get.rxtx[k]; + if ($elem.html() !== newVal) $elem.html(newVal); + } + for (let k=0; k < get.stat.length; k++) { + const $elem = $('#link'+k); + const newVal = get.stat[k]; + if ($elem.html() !== newVal) $elem.html(newVal); + } // current date and time $('#current_time').html(get.time[0]); $('#current_time_').html(get.time[0]); @@ -2696,7 +2719,7 @@ $(function() { next: (result) => { if (result.data?.systemMetricsCpu) { cpuchart.customData.cpuData = result.data.systemMetricsCpu; - cpuchart.customData.cpuLoad = Math.round(cpuchart.customData.cpuData.percentTotal); + cpuchart.customData.cpuLoad = Math.floor(cpuchart.customData.cpuData.percentTotal); //update cpu chart data addChartCpu(cpuchart.customData.cpuLoad); From a2e3f8507db89307c0035114458dc316c112feb1 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 17 Sep 2025 17:15:35 -0400 Subject: [PATCH 15/59] refactor: streamline view selection dropdown in DashStats page for improved user experience --- emhttp/plugins/dynamix/DashStats.page | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index e3c50d8bd..f26994a43 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -505,6 +505,12 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout +
@@ -531,12 +537,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout - +   _(Mode of operation)_ From 4699b4de6d7c5863d0c4f657815635059f552210 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Thu, 18 Sep 2025 01:38:35 -0400 Subject: [PATCH 16/59] Feat: Add note if PR appears to have been merged --- .github/scripts/generate-pr-plugin.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/scripts/generate-pr-plugin.sh b/.github/scripts/generate-pr-plugin.sh index 71d14a680..19f3ad168 100755 --- a/.github/scripts/generate-pr-plugin.sh +++ b/.github/scripts/generate-pr-plugin.sh @@ -271,7 +271,14 @@ Link='nav-user'
@@ -128,6 +144,15 @@ _(Excluded disk(s))_: :shares_excluded_disks_help: +_(Emptying disk(s))_: +: + +:shares_emptying_disks_help: + _(Permit exclusive shares)_: : "); - $('.tabs').append(""); - $('.tabs').append(""); + $('.tabs-container').append(""); + $('.tabs-container').append(""); + $('.tabs-container').append(""); }); From 7033b75ee7ee2eace62fdc201748600a71ef82a1 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Sat, 20 Sep 2025 18:02:45 -0400 Subject: [PATCH 31/59] Revert a different fix WIP --- emhttp/plugins/dynamix.plugin.manager/Plugins.page | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/Plugins.page b/emhttp/plugins/dynamix.plugin.manager/Plugins.page index 074e6797c..e4a30d177 100755 --- a/emhttp/plugins/dynamix.plugin.manager/Plugins.page +++ b/emhttp/plugins/dynamix.plugin.manager/Plugins.page @@ -58,7 +58,7 @@ function resize(bind) { $('#plugin_list').height(s); $('#plugin_table tbody tr:first-child td').each(function(){width.push($(this).width());}); $('#plugin_table thead tr th').each(function(i){$(this).width(width[i]);}); - if (!bind) $('#plugin_table,#plugin_table tbody').addClass('fixed'); + if (!bind) $('#plugin_table thead,#plugin_table tbody').addClass('fixed'); } } From 2b414a161d48894792f2a3da9515e81ff3b8e512 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Sat, 20 Sep 2025 18:34:49 -0400 Subject: [PATCH 32/59] Fix: PR message incorrect removal instructions --- .github/workflows/pr-plugin-upload.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-plugin-upload.yml b/.github/workflows/pr-plugin-upload.yml index eec65a7a7..7fdb224e7 100644 --- a/.github/workflows/pr-plugin-upload.yml +++ b/.github/workflows/pr-plugin-upload.yml @@ -359,9 +359,9 @@ jobs: ### 🔄 To Remove: - Navigate to Plugins → Installed Plugins and remove `webgui-pr-${{ steps.metadata.outputs.version }}`, or run: + Navigate to Plugins → Installed Plugins and remove `webgui-pr-${{ steps.metadata.outputs.pr_number }}`, or run: ```bash - plugin remove webgui-pr-${{ steps.metadata.outputs.version }} + plugin remove webgui-pr-${{ steps.metadata.outputs.pr_number }} ``` --- From 07e17871dd4aad355b190302484ef7d2c13be285 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Sat, 20 Sep 2025 19:03:13 -0400 Subject: [PATCH 33/59] Update Helpers.php --- emhttp/plugins/dynamix/include/Helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/Helpers.php b/emhttp/plugins/dynamix/include/Helpers.php index 529c88179..5c3dfacc5 100644 --- a/emhttp/plugins/dynamix/include/Helpers.php +++ b/emhttp/plugins/dynamix/include/Helpers.php @@ -386,7 +386,7 @@ function cpu_list() { } function my_explode($split, $text, $count=2) { - return array_pad(explode($split, $text, $count), $count, ''); + return array_pad(explode($split, $text??"", $count), $count, ''); } function my_preg_split($split, $text, $count=2) { From 26cc7645f70aa2e2bff69fa128c6e8b130833b78 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sun, 21 Sep 2025 17:31:55 +0200 Subject: [PATCH 34/59] Fixed jq syntax error (close if condition) --- emhttp/plugins/dynamix/agents/Discord.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index 52ccd3582..f086531fc 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -165,8 +165,8 @@ jq_filter=' # Description | .embeds[0].fields += [ { name: "Description", value: $desc_field } ] - | if ($desc_field2 | length) > 0 then .embeds[0].fields += [ { name: "Description (cont)", value: $desc_field2 } ] - | if ($desc_field3 | length) > 0 then .embeds[0].fields += [ { name: "Description (cont)", value: $desc_field3 } ] + | if ($desc_field2 | length) > 0 then .embeds[0].fields += [ { name: "Description (cont)", value: $desc_field2 } ] else . end + | if ($desc_field3 | length) > 0 then .embeds[0].fields += [ { name: "Description (cont)", value: $desc_field3 } ] else . end # Priority | .embeds[0].fields += [ { name: "Priority", value: $importance, inline: true } ] From 848eae6e2c1e6ad75dff4f18a96cf0c72114d132 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 23 Sep 2025 12:51:05 -0400 Subject: [PATCH 35/59] fix: reorganize copy and move input fields in Templates.php for improved clarity --- emhttp/plugins/dynamix/include/Templates.php | 72 ++++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php index 8532684f5..b453c4cf1 100644 --- a/emhttp/plugins/dynamix/include/Templates.php +++ b/emhttp/plugins/dynamix/include/Templates.php @@ -54,12 +54,6 @@ _(New folder name)_: _(Source folder)_: : -  -: _(copy to)_ ... - -_(Target folder)_: -: -   : +  +: _(copy to)_ ... + +_(Target folder)_: +: +
@@ -80,12 +80,6 @@ _(Target folder)_: _(Source folder)_: : -  -: _(move to)_ ... - -_(Target folder)_: -: -   : +  +: _(move to)_ ... + +_(Target folder)_: +: +
@@ -132,12 +132,6 @@ _(New file name)_: _(Source file)_: : -  -: _(copy to)_ ... - -_(Target file)_: -: -   : +  +: _(copy to)_ ... + +_(Target file)_: +: +
_(This copies the selected file)_
@@ -158,12 +158,6 @@ _(Target file)_: _(Source file)_: : -  -: _(move to)_ ... - -_(Target file)_: -: -   : +  +: _(move to)_ ... + +_(Target file)_: +: +
_(This moves the selected file)_
@@ -207,12 +207,6 @@ _(Target)_: _(Source)_: : -  -: _(copy to)_ ... - -_(Target)_: -: -   : +  +: _(copy to)_ ... + +_(Target)_: +: +
_(This copies all the selected sources)_
@@ -233,12 +233,6 @@ _(Target)_: _(Source)_: : -  -: _(move to)_ ... - -_(Target)_: -: -   : +  +: _(move to)_ ... + +_(Target)_: +: +
_(This moves all the selected sources)_
From f7e79a71cd86b7d360b0ab67d998f41ed03ccae3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 23 Sep 2025 12:54:23 -0400 Subject: [PATCH 36/59] fix: remove unnecessary style attribute from enter_view select element in DashStats.page --- emhttp/plugins/dynamix/DashStats.page | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index 28bff6383..d33605e74 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -505,7 +505,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout - From 30d9d9c8e7d5aa4008b9ed84fcf13329180ac8cd Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 23 Sep 2025 14:42:34 -0400 Subject: [PATCH 37/59] feat: adjust CPU chart canvas height and update value formatting for better readability --- emhttp/plugins/dynamix/DashStats.page | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index 28bff6383..39ea3ff6b 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -412,7 +412,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout ?> - + @@ -1447,7 +1447,7 @@ function getMillisPerPixel(timeInSeconds, containerId) { // SmoothieCharts initialization var cpuchart = new SmoothieChart({ millisPerPixel: (cpuline * 1000) / 600, // Safe initial value - minValue: 0, + minValue: -1, maxValue: 100, responsive: true, grid: { @@ -1466,10 +1466,10 @@ var cpuchart = new SmoothieChart({ minValueScale: 1.02, maxValueScale: 1.02, yMinFormatter: function(value) { - return Math.max(0, Math.floor(value)); + return Math.max(0, Math.floor(value)) + ' %'; }, yMaxFormatter: function(value) { - return Math.floor(value); + return Math.max(0, Math.min(100, Math.ceil(value))) + ' %'; } }); From 486dfda10b6a43131d9a8fc00a147af652ca8de3 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 23 Sep 2025 14:48:21 -0400 Subject: [PATCH 38/59] Fix: update background color for active navigation item --- emhttp/plugins/dynamix/styles/default-base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 7df257b1b..ebfa6ec31 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -631,7 +631,7 @@ div.title span img { background-color: var(--orange-800); } .nav-item.active:after { - background-color: var(--background-color); + background-color: var(--header-text-color); } .nav-user a { color: var(--inverse-text-color); From 226cf8c9113f6cd386033f08843e8f214f4d2080 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 23 Sep 2025 15:41:17 -0400 Subject: [PATCH 39/59] Update DashStats.page --- emhttp/plugins/dynamix/DashStats.page | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index d33605e74..c5044b921 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -505,7 +505,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout - From ae0d70ccef8e1f543b1a170c4924eec6be5ef611 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Tue, 23 Sep 2025 18:43:17 -0400 Subject: [PATCH 40/59] Fix: Disallow reading settings from share containing apostrophe --- emhttp/plugins/dynamix/SecurityNFS.page | 4 ++-- emhttp/plugins/dynamix/SecuritySMB.page | 12 ++++++------ emhttp/plugins/dynamix/ShareEdit.page | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/emhttp/plugins/dynamix/SecurityNFS.page b/emhttp/plugins/dynamix/SecurityNFS.page index e74674f2f..da4a9cf20 100755 --- a/emhttp/plugins/dynamix/SecurityNFS.page +++ b/emhttp/plugins/dynamix/SecurityNFS.page @@ -31,7 +31,7 @@ _(Read settings from)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name) echo mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name) echo mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name) echo mk_option("", $list['name'], compress($list['name'])); } ?> @@ -48,7 +48,7 @@ _(Write settings to)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name) $rows[] = mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name) $rows[] = mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name) $rows[] = mk_option("", $list['name'], compress($list['name'])); } if ($rows) echo ""; foreach ($rows as $row) echo $row; diff --git a/emhttp/plugins/dynamix/SecuritySMB.page b/emhttp/plugins/dynamix/SecuritySMB.page index 7991090dd..c3a7e679d 100755 --- a/emhttp/plugins/dynamix/SecuritySMB.page +++ b/emhttp/plugins/dynamix/SecuritySMB.page @@ -34,7 +34,7 @@ _(Read settings from)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name) echo mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name) echo mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name) echo mk_option("", $list['name'], compress($list['name'])); } ?> @@ -51,7 +51,7 @@ _(Write settings to)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name) $rows[] = mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name) $rows[] = mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name) $rows[] = mk_option("", $list['name'], compress($list['name'])); } if ($rows) echo ""; foreach ($rows as $row) echo $row; @@ -154,7 +154,7 @@ _(Read settings from)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='secure') echo mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='secure') echo mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name && $sec[$list['name']]['security']=='secure') echo mk_option("", $list['name'], compress($list['name'])); } ?> @@ -171,7 +171,7 @@ _(Write settings to)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='secure') $rows[] = mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='secure') $rows[] = mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name && $sec[$list['name']]['security']=='secure') $rows[] = mk_option("", $list['name'], compress($list['name'])); } if ($rows) echo ""; foreach ($rows as $row) echo $row; @@ -217,7 +217,7 @@ _(Read settings from)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='private') echo mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='private') echo mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name && $sec[$list['name']]['security']=='private') echo mk_option("", $list['name'], compress($list['name'])); } ?> @@ -234,7 +234,7 @@ _(Write settings to)_ if (isset($disks[$name])) { foreach (array_filter($disks,'clone_list') as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='private') $rows[] = mk_option("", $list['name'], _(my_disk($list['name']),3)); } else { - foreach ($shares as $list) if ($list['name']!=$name && $sec[$list['name']]['security']=='private') $rows[] = mk_option("", $list['name'], compress($list['name'])); + foreach ($shares as $list) if (strpos($list['name'],"'") === false && $list['name']!=$name && $sec[$list['name']]['security']=='private') $rows[] = mk_option("", $list['name'], compress($list['name'])); } if ($rows) echo ""; foreach ($rows as $row) echo $row; diff --git a/emhttp/plugins/dynamix/ShareEdit.page b/emhttp/plugins/dynamix/ShareEdit.page index 73f9ec998..53cc9cde0 100755 --- a/emhttp/plugins/dynamix/ShareEdit.page +++ b/emhttp/plugins/dynamix/ShareEdit.page @@ -327,7 +327,7 @@ function direction() { $myDisks = array_filter(array_diff(array_keys(array_filter($disks,'my_disks')), explode(',',$var['shareUserExclude'])), 'globalInclude'); $filteredShares = array_filter($shares, function($list) use ($name) { - return $list['name'] != $name || !$name; + return (strpos($list['name'],"'") === false) && ($list['name'] != $name || !$name) ; }); ?> :share_edit_global1_help: From b291f217ced2330f19c2a2e4bdd87962680b01c0 Mon Sep 17 00:00:00 2001 From: Zack Spear Date: Tue, 23 Sep 2025 17:54:50 -0700 Subject: [PATCH 41/59] Fix: adjust tooltip positioning to account for window scroll offsets --- .../dynamix/include/DefaultPageLayout/BodyInlineJS.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 322e81ca4..b275b9af2 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -504,9 +504,11 @@ $(document).on('mouseenter', 'a.info', function() { const tooltip = $(this).find('span'); if (tooltip.length) { const aInfoPosition = $(this).offset(); + const scrollTop = $(window).scrollTop(); + const scrollLeft = $(window).scrollLeft(); const addtionalOffset = 16; - const top = aInfoPosition.top + addtionalOffset; - const left = aInfoPosition.left + addtionalOffset; + const top = aInfoPosition.top - scrollTop + addtionalOffset; + const left = aInfoPosition.left - scrollLeft + addtionalOffset; tooltip.css({ top, left }); } }); From a6e966c673a4df7983087858dec05b8beea8dc94 Mon Sep 17 00:00:00 2001 From: ljm42 Date: Tue, 23 Sep 2025 21:04:43 -0700 Subject: [PATCH 42/59] fix: increase timeout when stopping samba --- etc/rc.d/rc.samba | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etc/rc.d/rc.samba b/etc/rc.d/rc.samba index 2b72ca7a2..8d30144f8 100755 --- a/etc/rc.d/rc.samba +++ b/etc/rc.d/rc.samba @@ -39,7 +39,7 @@ samba_running(){ samba_waitfor_shutdown(){ local i - for i in {1..5}; do + for i in {1..10}; do if ! samba_running; then break; fi sleep 1 done From 3facf57b4cdbe6c00386e8e4383c42870b49fea9 Mon Sep 17 00:00:00 2001 From: bergware Date: Wed, 24 Sep 2025 18:56:25 +0200 Subject: [PATCH 43/59] Wireguard: fix PHP error on import --- emhttp/plugins/dynamix/include/update.wireguard.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/update.wireguard.php b/emhttp/plugins/dynamix/include/update.wireguard.php index 5454f393e..1add2dd69 100644 --- a/emhttp/plugins/dynamix/include/update.wireguard.php +++ b/emhttp/plugins/dynamix/include/update.wireguard.php @@ -541,8 +541,9 @@ case 'import': $vpn = (in_array($default4,$vpn) || in_array($default6,$vpn)) ? 8 : 0; if ($vpn==8) $import["Address:$n"] = ''; $import["TYPE:$n"] = $vpn; - ipfilter(_var($import,"AllowedIPs:$n")); - if (_var($import,"TYPE:$n") == 0) $var['subnets1'] = "AllowedIPs="._var($import,"AllowedIPs:$n"); + $allowedIPs = _var($import,"AllowedIPs:$n") + ipfilter($allowedIPs); + if (_var($import,"TYPE:$n") == 0) $var['subnets1'] = "AllowedIPs=".$allowedIPs); } foreach ($import as $key => $val) $sort[] = explode(':',$key)[1]; array_multisort($sort, $import); From 48a1b4287f20611ac459ed2f3015f20e6c2bdcb4 Mon Sep 17 00:00:00 2001 From: bergware Date: Thu, 25 Sep 2025 10:00:01 +0200 Subject: [PATCH 44/59] wireguard: fix missing semi-colon --- .../dynamix/include/update.wireguard.php | 2 +- sbin/monitor_nchan.bash | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 sbin/monitor_nchan.bash diff --git a/emhttp/plugins/dynamix/include/update.wireguard.php b/emhttp/plugins/dynamix/include/update.wireguard.php index 1add2dd69..730c5c2c9 100644 --- a/emhttp/plugins/dynamix/include/update.wireguard.php +++ b/emhttp/plugins/dynamix/include/update.wireguard.php @@ -541,7 +541,7 @@ case 'import': $vpn = (in_array($default4,$vpn) || in_array($default6,$vpn)) ? 8 : 0; if ($vpn==8) $import["Address:$n"] = ''; $import["TYPE:$n"] = $vpn; - $allowedIPs = _var($import,"AllowedIPs:$n") + $allowedIPs = _var($import, "AllowedIPs:$n"); ipfilter($allowedIPs); if (_var($import,"TYPE:$n") == 0) $var['subnets1'] = "AllowedIPs=".$allowedIPs); } diff --git a/sbin/monitor_nchan.bash b/sbin/monitor_nchan.bash new file mode 100644 index 000000000..8a0e75017 --- /dev/null +++ b/sbin/monitor_nchan.bash @@ -0,0 +1,48 @@ +#!/bin/bash +docroot=/usr/local/emhttp # webGui root folder +nchan_pid=/var/run/nchan.pid # keeps list of nchan processes registered by GUI +disk_load=/var/local/emhttp/diskload.ini # disk load statistics +nginx=/var/run/nginx.socket # nginx local access +status=http://localhost/pub/session?buffer_length=1 # nchan information about GUI subscribers +nchan_list=/tmp/nchan_list.tmp +nchan_id=$(basename "$0") + +nchan_stop() { + echo -n >$nchan_list + pid=$(cat $pid_file) + while IFS=$'\n' read -r nchan; do + [[ ${nchan##*/} == '.*' ]] && continue + echo $nchan >>$nchan_list + pkill --ns $pid -f $nchan + done <<< $(ps -eo cmd | grep -Po '/usr/local/emhttp/.*/nchan/.*') +} + +nchan_start() { + [[ -e $nchan_list ]] || return + pid=$(cat $pid_file) + while IFS=$'\n' read -r nchan; do + if ! pgrep --ns $pid -f $nchan >/dev/null; then + $nchan &>/dev/null & + fi + done < $nchan_list + rm -f $nchan_list +} + +if [[ $1 == kill ]]; then + echo "Stopping nchan processes..." + nchan_stop + rm -f $nchan_pid $disk_load + exit +fi + +if [[ $1 == stop ]]; then + echo "Stopping nchan processes..." + nchan_stop + exit +fi + +if [[ $1 == start ]]; then + echo "Starting nchan processes..." + nchan_start + exit +fi From 724836bb970b18be088d5471435f3cb2c4afe335 Mon Sep 17 00:00:00 2001 From: bergware Date: Thu, 25 Sep 2025 10:02:01 +0200 Subject: [PATCH 45/59] Delete monitor_nchan.bash --- sbin/monitor_nchan.bash | 48 ----------------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 sbin/monitor_nchan.bash diff --git a/sbin/monitor_nchan.bash b/sbin/monitor_nchan.bash deleted file mode 100644 index 8a0e75017..000000000 --- a/sbin/monitor_nchan.bash +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -docroot=/usr/local/emhttp # webGui root folder -nchan_pid=/var/run/nchan.pid # keeps list of nchan processes registered by GUI -disk_load=/var/local/emhttp/diskload.ini # disk load statistics -nginx=/var/run/nginx.socket # nginx local access -status=http://localhost/pub/session?buffer_length=1 # nchan information about GUI subscribers -nchan_list=/tmp/nchan_list.tmp -nchan_id=$(basename "$0") - -nchan_stop() { - echo -n >$nchan_list - pid=$(cat $pid_file) - while IFS=$'\n' read -r nchan; do - [[ ${nchan##*/} == '.*' ]] && continue - echo $nchan >>$nchan_list - pkill --ns $pid -f $nchan - done <<< $(ps -eo cmd | grep -Po '/usr/local/emhttp/.*/nchan/.*') -} - -nchan_start() { - [[ -e $nchan_list ]] || return - pid=$(cat $pid_file) - while IFS=$'\n' read -r nchan; do - if ! pgrep --ns $pid -f $nchan >/dev/null; then - $nchan &>/dev/null & - fi - done < $nchan_list - rm -f $nchan_list -} - -if [[ $1 == kill ]]; then - echo "Stopping nchan processes..." - nchan_stop - rm -f $nchan_pid $disk_load - exit -fi - -if [[ $1 == stop ]]; then - echo "Stopping nchan processes..." - nchan_stop - exit -fi - -if [[ $1 == start ]]; then - echo "Starting nchan processes..." - nchan_start - exit -fi From 1f604e6bbef10b23c34ea591e95808c88a098fc0 Mon Sep 17 00:00:00 2001 From: bergware Date: Thu, 25 Sep 2025 11:17:20 +0200 Subject: [PATCH 46/59] fix typo --- emhttp/plugins/dynamix/include/update.wireguard.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/update.wireguard.php b/emhttp/plugins/dynamix/include/update.wireguard.php index 730c5c2c9..10edd7afd 100644 --- a/emhttp/plugins/dynamix/include/update.wireguard.php +++ b/emhttp/plugins/dynamix/include/update.wireguard.php @@ -543,7 +543,7 @@ case 'import': $import["TYPE:$n"] = $vpn; $allowedIPs = _var($import, "AllowedIPs:$n"); ipfilter($allowedIPs); - if (_var($import,"TYPE:$n") == 0) $var['subnets1'] = "AllowedIPs=".$allowedIPs); + if (_var($import,"TYPE:$n") == 0) $var['subnets1'] = "AllowedIPs=$allowedIPs"; } foreach ($import as $key => $val) $sort[] = explode(':',$key)[1]; array_multisort($sort, $import); From ea9966ae19da9e0808cd2da178bf89a61a3909db Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 25 Sep 2025 10:54:13 -0400 Subject: [PATCH 47/59] fix: update HTML structure in Templates.php to improve formatting and consistency of warning messages --- emhttp/plugins/dynamix/include/Templates.php | 63 ++++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php index b453c4cf1..b06c5d803 100644 --- a/emhttp/plugins/dynamix/include/Templates.php +++ b/emhttp/plugins/dynamix/include/Templates.php @@ -21,7 +21,8 @@ _(New folder name)_:   : -
_(This creates a folder at the current level)_
+  +:
_(This creates a folder at the current level)_
@@ -31,7 +32,8 @@ _(Folder name)_:   : -
+  +:
@@ -47,7 +49,8 @@ _(New folder name)_:   : -
_(This renames the folder to the new name)_
+  +:
_(This renames the folder to the new name)_
@@ -70,10 +73,13 @@ _(Source folder)_:   : _(copy to)_ ... +
 
+
+
+
+ _(Target folder)_: : - -
@@ -96,10 +102,13 @@ _(Source folder)_:   : _(move to)_ ... +
 
+
+
+
+ _(Target folder)_: : - -
@@ -109,7 +118,8 @@ _(File name)_:   : -
_(This deletes the selected file)_
+  +:
_(This deletes the selected file)_
@@ -125,7 +135,8 @@ _(New file name)_:   : -
_(This renames the selected file)_
+  +:
_(This renames the selected file)_
@@ -148,10 +159,13 @@ _(Source file)_:   : _(copy to)_ ... +
 
+
+
_(This copies the selected file)_
+
+ _(Target file)_: : - -
_(This copies the selected file)_
@@ -174,10 +188,13 @@ _(Source file)_:   : _(move to)_ ... +
 
+
+
_(This moves the selected file)_
+
+ _(Target file)_: : - -
_(This moves the selected file)_
@@ -187,7 +204,8 @@ _(Source)_:   : -
_(This deletes all selected sources)_
+  +:
_(This deletes all selected sources)_
@@ -200,7 +218,8 @@ _(Source)_: _(Target)_: : -
_(This renames the selected source)_
+  +:
_(This renames the selected source)_
@@ -223,10 +242,13 @@ _(Source)_:   : _(copy to)_ ... +
 
+
+
_(This copies all the selected sources)_
+
+ _(Target)_: : - -
_(This copies all the selected sources)_
@@ -249,10 +271,13 @@ _(Source)_:   : _(move to)_ ... +
 
+
+
_(This moves all the selected sources)_
+
+ _(Target)_: : - -
_(This moves all the selected sources)_
From 2b0244f19157893c87a6d6f50325ac50436e05a0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 25 Sep 2025 11:02:31 -0400 Subject: [PATCH 48/59] fix: Enhance DashStats UI: Update select elements for better layout and styling. Add new CSS rules for network-selects to improve responsiveness and alignment. --- emhttp/plugins/dynamix/DashStats.page | 6 ++--- emhttp/plugins/dynamix/sheets/DashStats.css | 25 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index b959f60bf..b685ad91f 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -498,14 +498,14 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout

_(Interface)_

- + - - diff --git a/emhttp/plugins/dynamix/sheets/DashStats.css b/emhttp/plugins/dynamix/sheets/DashStats.css index 71cdbbf6d..371ffb4e7 100644 --- a/emhttp/plugins/dynamix/sheets/DashStats.css +++ b/emhttp/plugins/dynamix/sheets/DashStats.css @@ -55,6 +55,31 @@ span.head_bar { span.head_gap { /* padding-left: 14px; */ } +span.head_gap.network-selects { + width: 100%; + display: flex; + align-items: center; + gap: 1rem; + flex: 1 1 auto; +} +span.head_gap.network-selects > * { + min-width: 0; +} +select.network-select { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +select.network-interface { + flex: 0 0 auto; + width: auto; +} +select.network-type { + flex: 1 1 auto; + width: 100%; +} span.head_time { padding-left: 40px; font-size: inherit !important; From 79de90e12e21ebd9f3c38e078afc58a3f4dab533 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Thu, 25 Sep 2025 14:54:58 -0400 Subject: [PATCH 49/59] Fix: Prevent GUI issues if clicking external link within a changelog --- .../plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index b275b9af2..7e997cfdf 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -344,6 +344,8 @@ $('body').on('click','a,.ca_href', function(e) { $.cookie('allowedDomains',JSON.stringify(domainsAllowed),{expires:3650}); // rewrite cookie to further extend expiration by 400 days if (domainsAllowed[dom.hostname]) return; e.preventDefault(); + + $('.sweet-alert').removeClass('nchan'); // Remove nchan class if present as display issues will result swal({ title: "", text: "

"+href+"

"+dom.hostname+"
", From fcc370450372a7a82d319e8554eb8a59767cdfa1 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Thu, 25 Sep 2025 14:57:03 -0400 Subject: [PATCH 50/59] Revert fix external links within changelog --- .../plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index 7e997cfdf..b275b9af2 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -344,8 +344,6 @@ $('body').on('click','a,.ca_href', function(e) { $.cookie('allowedDomains',JSON.stringify(domainsAllowed),{expires:3650}); // rewrite cookie to further extend expiration by 400 days if (domainsAllowed[dom.hostname]) return; e.preventDefault(); - - $('.sweet-alert').removeClass('nchan'); // Remove nchan class if present as display issues will result swal({ title: "", text: "

"+href+"

"+dom.hostname+"
", From c4b60f401ae26acc5742735117dc3772a309e231 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Thu, 25 Sep 2025 15:00:16 -0400 Subject: [PATCH 51/59] Update BodyInlineJS.php --- .../plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php | 1 + 1 file changed, 1 insertion(+) diff --git a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php index b275b9af2..bb25eb01e 100644 --- a/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php +++ b/emhttp/plugins/dynamix/include/DefaultPageLayout/BodyInlineJS.php @@ -344,6 +344,7 @@ $('body').on('click','a,.ca_href', function(e) { $.cookie('allowedDomains',JSON.stringify(domainsAllowed),{expires:3650}); // rewrite cookie to further extend expiration by 400 days if (domainsAllowed[dom.hostname]) return; e.preventDefault(); + $('.sweet-alert').removeClass('nchan'); // Prevent GUI issues if clicking external link from a changelog swal({ title: "", text: "

"+href+"

"+dom.hostname+"
", From d4be4e04b488ab1a6804fc6f392eed93c962e979 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Sat, 27 Sep 2025 01:19:53 -0400 Subject: [PATCH 52/59] Fix SMB read/write settings in tabbed mode --- emhttp/plugins/dynamix/SecuritySMB.page | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/SecuritySMB.page b/emhttp/plugins/dynamix/SecuritySMB.page index 7991090dd..d19bd0448 100755 --- a/emhttp/plugins/dynamix/SecuritySMB.page +++ b/emhttp/plugins/dynamix/SecuritySMB.page @@ -267,7 +267,7 @@ $(function() { checkShareSettingsSMB(document.smb_edit); initDropdownSMB(false); - $('#tab'+$('input[name$="tabs"]').length).bind({click:function(){initDropdownSMB(true);}}); + $('#tab'+$('.tabs-container button').length).bind({click:function(){initDropdownSMB(true);}}); toggleButton('readusersmb',true); From 5edcf161cdebcfef98594e52e8ce2edd22e9d06e Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Sat, 27 Sep 2025 14:58:02 -0400 Subject: [PATCH 53/59] Fix: Changelogs weren't getting the attributes --- .github/scripts/generate-pr-plugin.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/scripts/generate-pr-plugin.sh b/.github/scripts/generate-pr-plugin.sh index 19f3ad168..477d31d25 100755 --- a/.github/scripts/generate-pr-plugin.sh +++ b/.github/scripts/generate-pr-plugin.sh @@ -53,14 +53,11 @@ cat > "$PLUGIN_NAME" << 'EOF' icon="wrench" support="&github;/pull/≺"> - - From b19a8ecc3c6f62394180cc83f4c33b0cc946cbc3 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Sun, 28 Sep 2025 16:56:28 +0100 Subject: [PATCH 54/59] Fix Memory reporting. --- emhttp/plugins/dynamix/DashStats.page | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/DashStats.page b/emhttp/plugins/dynamix/DashStats.page index b685ad91f..5522666ae 100755 --- a/emhttp/plugins/dynamix/DashStats.page +++ b/emhttp/plugins/dynamix/DashStats.page @@ -169,11 +169,16 @@ foreach ($memory_array as $device) { if ($base>=1) $memory_maximum += $size*pow(1024,$base); if (!$ecc && isset($device['Error Correction Type']) && $device['Error Correction Type']!='None') $ecc = "{$device['Error Correction Type']} "; } +if ($memory_installed >= 1048576) { + $memory_installed = round($memory_installed/1048576); + $memory_maximum = round($memory_maximum/1048576); + $unit = 'TiB'; +} else { if ($memory_installed >= 1024) { $memory_installed = round($memory_installed/1024); $memory_maximum = round($memory_maximum/1024); - $unit = 'GiB'; -} else $unit = 'MiB'; + $unit = 'GiB';} +else $unit = 'MiB'; } // get system resources size exec("df --output=size /boot /var/log /var/lib/docker 2>/dev/null|awk '(NR>1){print $1*1024}'",$df); From f286b2da28a7396855fb9ff30712f6f3aec5a5a6 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 23 Sep 2025 14:02:12 -0400 Subject: [PATCH 55/59] Refactor: Improve layout and styling of diagnostics and configuration pages for better user experience --- emhttp/plugins/dynamix/Diagnostics.page | 23 ++++---- emhttp/plugins/dynamix/NewConfig.page | 48 +++++++++------- emhttp/plugins/dynamix/NewPerms.page | 74 +++++++++++++++---------- 3 files changed, 84 insertions(+), 61 deletions(-) diff --git a/emhttp/plugins/dynamix/Diagnostics.page b/emhttp/plugins/dynamix/Diagnostics.page index d710fc25d..c4b647096 100644 --- a/emhttp/plugins/dynamix/Diagnostics.page +++ b/emhttp/plugins/dynamix/Diagnostics.page @@ -110,15 +110,16 @@ to the system log.* *Use* **Anonymize diagnostics** *when publishing the diagnostics file in the public forum. In private communication with Limetech it is recommended to uncheck this setting and capture all information unaltered.* :end -
- - - - - - - +
+
+ +
+
+ + +
+
diff --git a/emhttp/plugins/dynamix/NewConfig.page b/emhttp/plugins/dynamix/NewConfig.page index 0f3205335..e9ec459a9 100644 --- a/emhttp/plugins/dynamix/NewConfig.page +++ b/emhttp/plugins/dynamix/NewConfig.page @@ -62,30 +62,38 @@ effect of making it ***impossible*** to rebuild an existing failed drive - you h -_(Preserve current assignments)_: -: +
+
+ _(Preserve current assignments)_: +
+ +
+
-  -: - - _(Array has been **Reset**)_ (_(please configure)_) - - _(Array must be **Stopped** to change)_ - -
diff --git a/emhttp/plugins/dynamix/NewPerms.page b/emhttp/plugins/dynamix/NewPerms.page index 3dc93c648..985b2d003 100644 --- a/emhttp/plugins/dynamix/NewPerms.page +++ b/emhttp/plugins/dynamix/NewPerms.page @@ -91,36 +91,50 @@ Closing the window before completion will terminate the background process - so Note that this tool may negatively affect any docker containers if you allow your **appdata** share to be included. :end -
- - - + +
+
+ _(Target)_: +
+ +
+
- - - +
+ _(Items)_: +
+
+ +
+ +
+
- - - -
- -
_(Array must be **Started** to change permissions)_.
- +
+ + + + + + + _(Array must be **Started** to change permissions)_. + +
+
From 21f47ce7476392e1b5318ff70db61b861656149f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 26 Sep 2025 14:27:26 -0400 Subject: [PATCH 56/59] Refactor: Update layout and styling for consistency across diagnostics, configuration, and permissions pages --- emhttp/plugins/dynamix/Diagnostics.page | 6 +++--- emhttp/plugins/dynamix/NewConfig.page | 12 ++++++------ emhttp/plugins/dynamix/NewPerms.page | 20 ++++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/emhttp/plugins/dynamix/Diagnostics.page b/emhttp/plugins/dynamix/Diagnostics.page index c4b647096..7cbba6656 100644 --- a/emhttp/plugins/dynamix/Diagnostics.page +++ b/emhttp/plugins/dynamix/Diagnostics.page @@ -110,15 +110,15 @@ to the system log.* *Use* **Anonymize diagnostics** *when publishing the diagnostics file in the public forum. In private communication with Limetech it is recommended to uncheck this setting and capture all information unaltered.* :end -
-
+
+
-
+
diff --git a/emhttp/plugins/dynamix/NewConfig.page b/emhttp/plugins/dynamix/NewConfig.page index e9ec459a9..53e50942e 100644 --- a/emhttp/plugins/dynamix/NewConfig.page +++ b/emhttp/plugins/dynamix/NewConfig.page @@ -62,11 +62,11 @@ effect of making it ***impossible*** to rebuild an existing failed drive - you h -
-
- _(Preserve current assignments)_: +
+
+ _(Preserve current assignments)_:
- @@ -74,7 +74,7 @@ effect of making it ***impossible*** to rebuild an existing failed drive - you h
-
+
_(Array has been **Reset**)_ (_(please configure)_) @@ -91,7 +91,7 @@ effect of making it ***impossible*** to rebuild an existing failed drive - you h
-
+
diff --git a/emhttp/plugins/dynamix/NewPerms.page b/emhttp/plugins/dynamix/NewPerms.page index 985b2d003..70f5726f2 100644 --- a/emhttp/plugins/dynamix/NewPerms.page +++ b/emhttp/plugins/dynamix/NewPerms.page @@ -92,9 +92,9 @@ Note that this tool may negatively affect any docker containers if you allow you :end
-
-
- _(Target)_: +
+
+ _(Target)_:
+
-
-
+
From 94cf4c526230d7fddbba0f173c9635a3609c8a93 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Tue, 30 Sep 2025 15:39:24 -0400 Subject: [PATCH 57/59] Remove debugging information --- emhttp/plugins/dynamix/include/publish.php | 1 - 1 file changed, 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/publish.php b/emhttp/plugins/dynamix/include/publish.php index 880f941f6..b50ff0286 100755 --- a/emhttp/plugins/dynamix/include/publish.php +++ b/emhttp/plugins/dynamix/include/publish.php @@ -83,7 +83,6 @@ function publish($endpoint, $message, $len=1, $abort=false, $abortTime=30) { $abortStart[$endpoint] = time(); if ( (time() - $abortStart[$endpoint]) > $abortTime) { $script = removeNChanScript(); - my_logger("$script timed out after $abortTime seconds. Exiting.", 'publish'); exit(); } $reply = false; // if no subscribers, force return value to false From 96188ea5821e0ecbb7172020117ab039dbffe6d1 Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Wed, 1 Oct 2025 01:03:57 -0400 Subject: [PATCH 58/59] Fix VM display aberrations --- emhttp/plugins/dynamix.vm.manager/VMMachines.page | 0 emhttp/plugins/dynamix.vm.manager/VMUsageStats.page | 2 +- emhttp/plugins/dynamix.vm.manager/VMs.page | 7 +------ 3 files changed, 2 insertions(+), 7 deletions(-) mode change 100644 => 100755 emhttp/plugins/dynamix.vm.manager/VMMachines.page mode change 100644 => 100755 emhttp/plugins/dynamix.vm.manager/VMs.page diff --git a/emhttp/plugins/dynamix.vm.manager/VMMachines.page b/emhttp/plugins/dynamix.vm.manager/VMMachines.page old mode 100644 new mode 100755 diff --git a/emhttp/plugins/dynamix.vm.manager/VMUsageStats.page b/emhttp/plugins/dynamix.vm.manager/VMUsageStats.page index 14ad65693..e957f8ff1 100755 --- a/emhttp/plugins/dynamix.vm.manager/VMUsageStats.page +++ b/emhttp/plugins/dynamix.vm.manager/VMUsageStats.page @@ -1,4 +1,4 @@ -Menu="VMs:0" +Menu="VMs:2" Title="VM Usage Statistics" Nchan="vm_usage" Cond="exec(\"grep -o '^USAGE=.Y' /boot/config/domain.cfg 2>/dev/null\") && is_file('/var/run/libvirt/libvirtd.pid')" diff --git a/emhttp/plugins/dynamix.vm.manager/VMs.page b/emhttp/plugins/dynamix.vm.manager/VMs.page old mode 100644 new mode 100755 index 51528d0d0..1c73df341 --- a/emhttp/plugins/dynamix.vm.manager/VMs.page +++ b/emhttp/plugins/dynamix.vm.manager/VMs.page @@ -2,6 +2,7 @@ Menu="Tasks:70" Type="xmenu" Code="e918" Lock="true" +Tabs="true" Cond="exec(\"grep -o '^SERVICE=.enable' /boot/config/domain.cfg 2>/dev/null\")" --- /dev/null\")"

- - \ No newline at end of file From 0defab35bd305064c452d8216f9f95c51c5fd489 Mon Sep 17 00:00:00 2001 From: Tom Mortensen Date: Wed, 1 Oct 2025 11:11:10 -0700 Subject: [PATCH 59/59] help: remove line that says exclusive shares cannot be NFs exported --- emhttp/languages/en_US/helptext.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/emhttp/languages/en_US/helptext.txt b/emhttp/languages/en_US/helptext.txt index 84be54672..bb97abc2f 100644 --- a/emhttp/languages/en_US/helptext.txt +++ b/emhttp/languages/en_US/helptext.txt @@ -1548,7 +1548,6 @@ provided the following conditions are met: * The Primary storage for a share is set to a pool. * The Secondary storage for a share is set to **none**. * The share exists on a single volume. -* The share is **not** exported over NFS. The advantage of *exclusive* shares is that transfers bypass the FUSE layer which may significantly increase I/O performance.