From 18b7d51c1a06427fa6e8c64b161869de7daded7e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Nov 2026 01:37:11 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A6=84=20Sync=202026-11-08=2001:37:11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-trafficctl/Makefile | 4 +- .../resources/view/trafficctl/status.js | 61 ++++++++++++++++--- .../po/templates/luci-app-trafficctl.pot | 3 + .../root/usr/local/bin/trafficctl-device.sh | 49 +++++++-------- .../root/usr/local/bin/trafficctl-fw.sh | 42 ++++++++++++- .../root/usr/local/bin/trafficctl-summary.sh | 49 +++++++-------- 6 files changed, 141 insertions(+), 67 deletions(-) diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index b14bd30b3..ff13579d0 100644 --- a/luci-app-trafficctl/Makefile +++ b/luci-app-trafficctl/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-trafficctl -PKG_VERSION:=1.26.3 -PKG_RELEASE:=21 +PKG_VERSION:=1.26.4 +PKG_RELEASE:=22 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Denis Iusupov diff --git a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js index e85d87fce..868b0a7f9 100644 --- a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js +++ b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js @@ -316,6 +316,14 @@ function fmtRate(kbit) { return kbit + ' ' + _('kbit/s'); } +// A ceiling as one figure, or both when upload has its own (#66). An upload +// figure of 0 means the backend found no upload ceiling to report; the +// download one is still the limit, so it is shown alone. +function fmtCeiling(kbit, kbitUp) { + if (!kbitUp || kbitUp <= 0 || kbitUp === kbit) return fmtRate(kbit); + return '↓ ' + fmtRate(kbit) + ' / ↑ ' + fmtRate(kbitUp); +} + // Render a "↓ down / ↑ up" speed cell. Both halves come from the same // bytes_in/bytes_out sample, so they are always consistent with each other. function renderSpeedCell(cell, sd) { @@ -1240,6 +1248,7 @@ function buildSummaryTable(rows, sortCol, sortDir, onSort, onSelect, speedMap, d r._backlog = sh ? sh.backlog : 0; r._throttle_kbit = (r.shape_kbit || 0) > 0 ? r.shape_kbit : (r.rate_limit_kbit || 0); r._throttle_mode = (r.shape_kbit || 0) > 0 ? 'shaper' : ((r.rate_limit_kbit || 0) > 0 ? 'limiter' : 'none'); + r._throttle_kbit_up = r._throttle_mode === 'shaper' ? (r.shape_kbit_up || 0) : (r.rate_limit_kbit_up || 0); }); var sorted = rows.slice().sort(function(a, b) { @@ -1381,8 +1390,8 @@ function buildSummaryTable(rows, sortCol, sortDir, onSort, onSelect, speedMap, d cellMap.app = E('div', { 'class': 'td tc-center' }, appBadge); var throttleBadge; - if (r._throttle_mode === 'shaper') { throttleBadge = E('span', { 'class': 'tc-c-speed tc-fw-bold', 'title': _('Shaper (tc/HTB queue)') }, '≈ ' + fmtRate(r._throttle_kbit)); } - else if (r._throttle_mode === 'limiter') { throttleBadge = E('span', { 'class': 'tc-c-warn tc-fw-bold', 'title': _('Limiter (nft drop)') }, '⚡ ' + fmtRate(r._throttle_kbit)); } + if (r._throttle_mode === 'shaper') { throttleBadge = E('span', { 'class': 'tc-c-speed tc-fw-bold', 'title': _('Shaper (tc/HTB queue)') }, '≈ ' + fmtCeiling(r._throttle_kbit, r._throttle_kbit_up)); } + else if (r._throttle_mode === 'limiter') { throttleBadge = E('span', { 'class': 'tc-c-warn tc-fw-bold', 'title': _('Limiter (nft drop)') }, '⚡ ' + fmtCeiling(r._throttle_kbit, r._throttle_kbit_up)); } else { throttleBadge = E('span', { 'class': 'tc-c-faint' }, '—'); } cellMap._throttle_kbit = E('div', { 'class': 'td tc-center' }, throttleBadge); @@ -1868,6 +1877,8 @@ return view.extend({ } renderRecentChips(); updateModeUI(); + // The split row describes the device it was opened for. + _upPinned = false; runQuery(); } @@ -2274,6 +2285,9 @@ return view.extend({ // it would close the field (and overwrite what is being typed) a few // seconds after it was opened. var _customPinned = false; + // The upload row's counterpart: set while the operator has it open + // for editing, so the poll does not refill or close it under them. + var _upPinned = false; var _modeSelected = 'shaper'; var rateChipsRow = E('div', {'class':'tc-chips-row'}); @@ -2379,13 +2393,29 @@ return view.extend({ var upToggleBtn = E('span', {'class': 'tc-chip', 'data-tip': _('Give upload its own ceiling instead of matching download') }, '⇅ ' + _('Split up/down')); - upToggleBtn.addEventListener('click', function() { - upRow.classList.toggle('tc-hidden'); - var on = !upRow.classList.contains('tc-hidden'); + function showUpRow(on) { + upRow.classList.toggle('tc-hidden', !on); upToggleBtn.classList.toggle('tc-chip--active', on); - if (on) { upInput.focus(); } else { upInput.value = ''; } + if (!on) { upInput.value = ''; } + } + upToggleBtn.addEventListener('click', function() { + var on = upRow.classList.contains('tc-hidden'); + showUpRow(on); + _upPinned = on; + if (on) { upInput.focus(); } updateScopeExplain(); }); + // The row has no button of its own: Enter applies the pair, as does + // picking a download rate. + upInput.addEventListener('keydown', function(ev) { + if (ev.key !== 'Enter') { return; } + ev.preventDefault(); + if (getRateKbit() === '0') { + setStatus(statusDiv, 'error', _('Pick a download speed as well: the upload one is applied with it.')); + return; + } + applyRate(); + }); rateChipsRow.appendChild(upToggleBtn); // Mode: segmented toggle (Shaper default) @@ -2634,6 +2664,7 @@ return view.extend({ scopeRow, rateChipsRow, customRow, + upRow, subnetLimitsBox ]); @@ -2680,6 +2711,7 @@ return view.extend({ var name = ''; var kbit = getRateKbit(); var kbitUp = getRateKbitUp(); + _upPinned = false; // A block target has no single tc classid, so the shaper can't // express it — force the limiter rather than silently doing nothing. var mode = all ? 'limiter' : _modeSelected; @@ -3119,12 +3151,12 @@ return view.extend({ } } if ((data.shape_kbit || 0) > 0) { - parts.push(_('Shaped: 🌊 %s').format(fmtRate(data.shape_kbit))); + parts.push(_('Shaped: 🌊 %s').format(fmtCeiling(data.shape_kbit, data.shape_kbit_up))); var sm = self._shapeMap[data.ip || searchSelect.getValue()] || {}; if ((sm.backlog||0) > 0) parts.push(_('Queued: %s').format(fmtBytes(sm.backlog))); if ((sm.bytes||0) > 0) parts.push(_('Passed: %s').format(fmtBytes(sm.bytes))); } else if ((data.rate_limit_kbit || 0) > 0) { - parts.push(_('Speed limit: ⚡ %s').format(fmtRate(data.rate_limit_kbit))); + parts.push(_('Speed limit: ⚡ %s').format(fmtCeiling(data.rate_limit_kbit, data.rate_limit_kbit_up))); var dm = self._dropMap[data.ip || searchSelect.getValue()] || {}; if ((dm.packets||0) > 0) { parts.push(_('Dropped: 🚫 %d pkts / %s').format(Number(dm.packets) || 0, fmtBytes(dm.bytes||0))); @@ -3175,6 +3207,19 @@ return view.extend({ customRow.classList.add('tc-hidden'); } + // Show the upload ceiling that is installed, so reopening a + // device with a split limit does not look symmetric. + var curUp = curShapeRate > 0 ? (data.shape_kbit_up || 0) : (data.rate_limit_kbit_up || 0); + if (!_upPinned && document.activeElement !== upInput) { + var split = curRate > 0 && curUp > 0 && curUp !== curRate; + showUpRow(split); + if (split) { + _upUnit = curUp % 1000 === 0 ? 'mbit' : 'kbit'; + upInput.value = _upUnit === 'mbit' ? curUp / 1000 : curUp; + updateUpUnitBtns(); + } + } + while (connsDiv.firstChild) connsDiv.removeChild(connsDiv.firstChild); if (!data.connections || data.connections.length === 0) { connsDiv.appendChild(E('p', {'style':'color:var(--tc-muted);padding:12px 0'}, _('No active connections.'))); diff --git a/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot b/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot index 71b6d798d..e3c563754 100644 --- a/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot +++ b/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot @@ -751,6 +751,9 @@ msgstr "" msgid "Persist rules" msgstr "" +msgid "Pick a download speed as well: the upload one is applied with it." +msgstr "" + msgid "Pick a rate to apply it to this target, or Off to remove its limit." msgstr "" diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh index 46bc41f3a..87cf6a43c 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh @@ -161,38 +161,35 @@ if [ -n "$MAC" ]; then done fi -# Get rate limit -RATE_LIM=0 +# Get rate limit, each direction: upload has its own ceiling since #83. +RATE_LIM=0; RATE_LIM_UP=0 if [ "$TCTL_FW" = "nft" ]; then - RATE_LIM=$(nft list table netdev tm_ratelimit 2>/dev/null | tctl_rl_rate_kbit "$IP") + RL_DUMP=$(nft list table netdev tm_ratelimit 2>/dev/null) + RATE_LIM=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$IP") + RATE_LIM_UP=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$IP" saddr) else # The address as a whole field, not a substring (see tctl_rl_rate_kbit). - RATE_LIM=$(iptables -t mangle -L FORWARD -nv 2>/dev/null | grep "rl_ratelimit" \ - | awk -v ip="$IP" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }' \ - | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') + # The download rule is listed first; the upload one is the "_ul" name. + RL_DUMP=$(iptables -t mangle -L FORWARD -nv 2>/dev/null | grep "rl_ratelimit" \ + | awk -v ip="$IP" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }') + RATE_LIM=$(echo "$RL_DUMP" | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') + RATE_LIM_UP=$(echo "$RL_DUMP" | grep -E 'rl_ratelimit.*_ul' | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') fi [ -z "$RATE_LIM" ] && RATE_LIM=0 +[ -z "$RATE_LIM_UP" ] && RATE_LIM_UP=0 -# Get shape rate -SHAPE_KBIT=0 -o3=$(echo "$IP" | cut -d. -f3) -o4=$(echo "$IP" | cut -d. -f4) -dec=$((o3 * 256 + o4)) -hex=$(printf "%x" "$dec") -classid="1:$hex" -# Skip reserved HTB classes (root 1:1 and default 1:fffe) -shape_info="" -case "$hex" in 1|fffe) ;; *) shape_info=$(tc class show dev "$LAN_DEV" classid "$classid" 2>/dev/null) ;; esac -if [ -n "$shape_info" ]; then - SHAPE_KBIT=$(echo "$shape_info" | grep -oE 'rate [0-9]+[A-Za-z]+' | head -1 | awk '{ - rate=$2; num=rate+0 - if (rate ~ /Gbit/) print num*1000000 - else if (rate ~ /Mbit/) print num*1000 - else if (rate ~ /[Kk]bit/) print num - else print num - }') +# Get shape rate. The classid is looked up in shapes.json, never derived from +# the address: minors are allocated (see lookup_shape_kbit in +# trafficctl-summary.sh). Download is the class on the LAN device, upload the +# one with the same classid on the IFB. +SHAPE_KBIT=0; SHAPE_KBIT_UP=0 +classid=$(tctl_shape_classmap | awk -v ip="$IP" '$1==ip{print $2; exit}') +if [ -n "$classid" ]; then + SHAPE_KBIT=$(tctl_tc_class_rates "$LAN_DEV" | awk -v c="$classid" '$1==c{print $2; exit}') + SHAPE_KBIT_UP=$(tctl_tc_class_rates "$(tctl_shape_ifb_dev)" | awk -v c="$classid" '$1==c{print $2; exit}') fi [ -z "$SHAPE_KBIT" ] && SHAPE_KBIT=0 +[ -z "$SHAPE_KBIT_UP" ] && SHAPE_KBIT_UP=0 # Parse conntrack for connections and totals TIMESTAMP=$(date +%s) @@ -404,7 +401,7 @@ tctl_wifi_block_pending "$([ "$WIFI_BLOCKED" = "true" ] && echo 1 || echo 0)" "$ && WIFI_PENDING=true # Output final JSON -printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","timestamp":%d,"blocked":%s,"block_packets":%d,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"total":%.0f,"protocols":{"tcp":%d,"udp":%d,"other":%d},"tcp_states":{"established":%d,"time_wait":%d,"syn_sent":%d,"close_wait":%d},"connections":[%s],"rate_limit_kbit":%d,"shape_kbit":%d}\n' \ +printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","timestamp":%d,"blocked":%s,"block_packets":%d,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"total":%.0f,"protocols":{"tcp":%d,"udp":%d,"other":%d},"tcp_states":{"established":%d,"time_wait":%d,"syn_sent":%d,"close_wait":%d},"connections":[%s],"rate_limit_kbit":%d,"rate_limit_kbit_up":%d,"shape_kbit":%d,"shape_kbit_up":%d}\n' \ "$IP" "$NAME" "$MAC" "$CONN_TYPE" "$CONN_LAST" "$TIMESTAMP" "$BLOCKED" "$BLOCK_PACKETS" "$BLOCK_BYTES" \ "$WIFI_BLOCKED" "$WIFI_PENDING" "$TOTAL" "$N_TCP" "$N_UDP" "$N_OTHER" \ - "$EST" "$TW" "$SS" "$CW" "$CONNS_OUT" "$RATE_LIM" "$SHAPE_KBIT" + "$EST" "$TW" "$SS" "$CW" "$CONNS_OUT" "$RATE_LIM" "$RATE_LIM_UP" "$SHAPE_KBIT" "$SHAPE_KBIT_UP" diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh index d2d521f83..c75484ee0 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh @@ -284,7 +284,7 @@ tctl_ratelimit_add() { --hashlimit-mode dstip --hashlimit-name "rl_${comment}" \ -j DROP -m comment --comment "$comment" 2>/dev/null iptables -t mangle -A FORWARD -s "$ip" -m hashlimit \ - --hashlimit-above "${rate_kbit}kbit/sec" --hashlimit-burst "${rate_kbit}kbit" \ + --hashlimit-above "${rate_kbit_up}kbit/sec" --hashlimit-burst "${rate_kbit_up}kbit" \ --hashlimit-mode srcip --hashlimit-name "rl_${comment}_ul" \ -j DROP -m comment --comment "${comment}_ul" 2>/dev/null fi @@ -339,12 +339,16 @@ tctl_ratelimit_remove() { # The readers that only knew "kbytes" turned the latter into 0 — or, in the # stats, into 8 kbit/s. Every unit goes back to bytes by nft's factor, and # bytes to kbit by the package's own, SI (see tctl_rate_bytes). +# +# The second argument picks the direction: "daddr" (the default) is the +# download rule, "saddr" the IPv4 upload one, which carries its own ceiling +# since the two directions could differ (#83). tctl_rl_rate_kbit() { - awk -v ip="$1" "$TCTL_NFT_RATE_AWK"' + awk -v ip="$1" -v fld="${2:-daddr}" "$TCTL_NFT_RATE_AWK"' /limit rate over/ { hit = 0 for (i = 1; i < NF; i++) - if ($i == "daddr" && ($(i+1) == ip || $(i+1) == ip "/32")) { hit = 1; break } + if ($i == fld && ($(i+1) == ip || $(i+1) == ip "/32")) { hit = 1; break } if (!hit) next for (j = i; j < NF - 1; j++) if ($j == "over") { printf "%.0f\n", tctl_rate_kbit($(j+1), $(j+2)); exit } @@ -1068,6 +1072,38 @@ tctl_shape_classmap() { }' } +# The IFB device upload shaping runs on (see trafficctl-shape.sh). +tctl_shape_ifb_dev() { + local d + d=$(uci -q get trafficctl.main.shape_ifb 2>/dev/null) + echo "${d:-tctl-ifb0}" +} + +# "classid rate_kbit" for every per-device HTB class on a device, from one tc +# call. On the LAN device that is the download ceiling, on the IFB the upload +# one: a shape uses the same classid on both. +tctl_tc_class_rates() { + command -v tc >/dev/null 2>&1 || return 0 + tc class show dev "$1" 2>/dev/null | awk ' + /^class htb 1:/ { + classid=$3 + minor=classid; sub(/^1:/,"",minor) + if (minor=="1" || minor=="fffe") next + rate=0 + for (i=1;i<=NF;i++) { + if ($i=="rate") { + v=$(i+1) + if (v ~ /Gbit/) { sub(/Gbit/,"",v); rate=(v+0)*1000000 } + else if (v ~ /Mbit/) { sub(/Mbit/,"",v); rate=(v+0)*1000 } + else if (v ~ /[Kk]bit/){ sub(/[Kk]bit/,"",v); rate=v+0 } + else { rate=(v+0)/1000 } + break + } + } + print classid, rate + }' +} + tctl_seen_normalize() { printf '%s' "$1" | tr 'A-Z' 'a-z' } diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh index efc51757d..d5a62fdf0 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh @@ -220,27 +220,10 @@ else RL_DUMP=$(iptables -t mangle -L FORWARD -nv 2>/dev/null) fi -# tc HTB classes → "classid rate_kbit" map (one tc call, parsed once) -TC_MAP="" -if command -v tc >/dev/null 2>&1; then - TC_MAP=$(tc class show dev "$PRIMARY_LAN_DEV" 2>/dev/null | awk ' - /^class htb 1:/ { - classid=$3 - minor=classid; sub(/^1:/,"",minor) - if (minor=="1" || minor=="fffe") next - rate=0 - for (i=1;i<=NF;i++) { - if ($i=="rate") { - v=$(i+1) - if (v ~ /Gbit/) { sub(/Gbit/,"",v); rate=(v+0)*1000000 } - else if (v ~ /Mbit/) { sub(/Mbit/,"",v); rate=(v+0)*1000 } - else if (v ~ /[Kk]bit/){ sub(/[Kk]bit/,"",v); rate=v+0 } - break - } - } - print classid, rate - }') -fi +# tc HTB classes → "classid rate_kbit" maps (one tc call per device, parsed +# once): download on the LAN device, upload on the IFB. +TC_MAP=$(tctl_tc_class_rates "$PRIMARY_LAN_DEV") +TC_MAP_UP=$(tctl_tc_class_rates "$(tctl_shape_ifb_dev)") # address → classid, from shapes.json (see tctl_shape_classmap). SHAPE_MAP=$(tctl_shape_classmap) @@ -348,13 +331,17 @@ lookup_block_bytes() { echo "${b:-0}" } +# $2 "up" reads the upload ceiling instead of the download one. lookup_rate_limit() { - local ip="$1" r + local ip="$1" r fld=daddr pat='rl_ratelimit' + [ "$2" = "up" ] && { fld=saddr; pat='rl_ratelimit.*_ul'; } if [ "$TCTL_FW" = "nft" ]; then - r=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$ip") + r=$(echo "$RL_DUMP" | tctl_rl_rate_kbit "$ip" "$fld") else # The address as a whole field, not a substring (see tctl_rl_rate_kbit). - r=$(echo "$RL_DUMP" | grep "rl_ratelimit" \ + # The download rule is listed first, so it is the first match either + # way; the upload one is the only "_ul" name. + r=$(echo "$RL_DUMP" | grep -E "$pat" \ | awk -v ip="$ip" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }' \ | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//') fi @@ -365,11 +352,15 @@ lookup_rate_limit() { # allocated, so deriving put the first shaped device's rate on 192.168.x.2 and # showed 0 for the device actually shaped — and the Telegram bot built its # "Shape — Remove" button from this field. +# +# $2 "up" reads the upload class on the IFB. 0 there with a download shape set +# means upload is not shaped at all (no kmod-ifb), not that it matches. lookup_shape_kbit() { - local ip="$1" classid r + local ip="$1" classid r map="$TC_MAP" + [ "$2" = "up" ] && map="$TC_MAP_UP" classid=$(echo "$SHAPE_MAP" | awk -v ip="$ip" '$1==ip{print $2; exit}') [ -n "$classid" ] || { echo 0; return; } - r=$(echo "$TC_MAP" | awk -v c="$classid" '$1==c{print $2; exit}') + r=$(echo "$map" | awk -v c="$classid" '$1==c{print $2; exit}') echo "${r:-0}" } @@ -402,7 +393,9 @@ for ip in $ACTIVE_IPS; do BLOCK_BYTES=$(lookup_block_bytes "$ip") WIFI_BLK=$(lookup_wifi_blocked "$MAC") RATE_LIM=$(lookup_rate_limit "$ip") + RATE_LIM_UP=$(lookup_rate_limit "$ip" up) SHAPE=$(lookup_shape_kbit "$ip") + SHAPE_UP=$(lookup_shape_kbit "$ip" up) APP=$(lookup_app "$ip") if [ -z "$NAME" ]; then NAME="*" @@ -462,13 +455,13 @@ for ip in $ACTIVE_IPS; do else printf "," fi - printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","app":"%s","conns":%d,"total":%.0f,"tcp":%.0f,"udp":%.0f,"blocked":%s,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"rate_limit_kbit":%.0f,"shape_kbit":%.0f}' \ + printf '{"ip":"%s","name":"%s","mac":"%s","conn_type":"%s","conn_last":"%s","app":"%s","conns":%d,"total":%.0f,"tcp":%.0f,"udp":%.0f,"blocked":%s,"block_bytes":%.0f,"wifi_blocked":%s,"wifi_block_pending":%s,"rate_limit_kbit":%.0f,"rate_limit_kbit_up":%.0f,"shape_kbit":%.0f,"shape_kbit_up":%.0f}' \ "$ip" "$NAME" "$MAC" "$CONN_TYPE" "$CONN_LAST" "$APP" "$CONNS" "$TOTAL" "$TCP" "$UDP" \ "$([ "$BLOCKED" = "1" ] && echo true || echo false)" \ "$BLOCK_BYTES" \ "$([ "$WIFI_BLK" = "1" ] && echo true || echo false)" \ "$([ "$WIFI_PENDING" = "1" ] && echo true || echo false)" \ - "$RATE_LIM" "$SHAPE" + "$RATE_LIM" "$RATE_LIM_UP" "$SHAPE" "$SHAPE_UP" done printf "]\n"