diff --git a/cpulimit/Makefile b/cpulimit/Makefile index 9aa7a450f..6ebc527bb 100644 --- a/cpulimit/Makefile +++ b/cpulimit/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=cpulimit -PKG_VERSION:=1.0.1 -PKG_RELEASE:=2 +PKG_VERSION:=1.0.2 +PKG_RELEASE:=3 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/denji/cpulimit/tar.gz/$(PKG_VERSION)? diff --git a/filebrowser-q/Makefile b/filebrowser-q/Makefile index 84a8cceba..9bcbb2c5e 100644 --- a/filebrowser-q/Makefile +++ b/filebrowser-q/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=filebrowser-q -PKG_VERSION:=2.0.1-stable -PKG_RELEASE:=10 +PKG_VERSION:=2.0.2-stable +PKG_RELEASE:=11 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)? diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index 2ab190679..21fa268ed 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.354 -PKG_RELEASE:=43 +PKG_VERSION:=0.1.356 +PKG_RELEASE:=44 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index b97d834bc..1ab4912e3 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.24.0 -PKG_RELEASE:=15 +PKG_VERSION:=1.25.0 +PKG_RELEASE:=16 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Denis Iusupov diff --git a/luci-app-trafficctl/root/etc/config/trafficctl b/luci-app-trafficctl/root/etc/config/trafficctl index 1d707bf5d..b08199d03 100644 --- a/luci-app-trafficctl/root/etc/config/trafficctl +++ b/luci-app-trafficctl/root/etc/config/trafficctl @@ -96,6 +96,15 @@ config history 'history' # How many FINISHED periods to retain. The current one is never a # retention candidate. option keep '12' + # Also accumulate per-INTERFACE totals (#102), in their own file per + # period. A second series rather than a breakdown of the device one: these + # are the kernel's interface counters, so they include the router's own + # traffic and broadcast, they legitimately disagree with the sum over + # devices, and under flow offloading they are the MORE accurate of the two + # because they come from the driver rather than from conntrack. They also + # answer what the device series cannot: how much went out of wan1 versus + # wan2. Off is reasonable on a single-WAN router. + option interfaces '1' config logging 'logging' option enabled '1' diff --git a/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl b/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl index 54e89b7cb..aee68ae81 100755 --- a/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl +++ b/luci-app-trafficctl/root/usr/libexec/rpcd/luci.trafficctl @@ -73,9 +73,10 @@ list) printf '"activity_log":{"lines":"int"},' printf '"history_status":{},' printf '"history_read":{"period":"str","to":"str"},' + printf '"history_read_ifaces":{"period":"str","to":"str"},' printf '"history_periods":{},' printf '"history_config_get":{},' - printf '"history_config_set":{"enabled":"bool","dir":"str","period":"str","interval":"int","sample_interval":"int","keep":"int"},' + printf '"history_config_set":{"enabled":"bool","dir":"str","period":"str","interval":"int","sample_interval":"int","keep":"int","interfaces":"bool"},' printf '"history_flush":{},' printf '"version":{}' printf '}' @@ -633,6 +634,26 @@ call) $SCRIPTS/trafficctl-history.sh read "$hp" "$ht" printf '}' ;; + history_read_ifaces) + read -r input + # Same shape check as history_read: these ids never reach a path, but + # they come from the network, so a malformed one is rejected rather + # than passed through to match nothing. + hp=$(echo "$input" | jsonfilter -e '@.period' 2>/dev/null) + ht=$(echo "$input" | jsonfilter -e '@.to' 2>/dev/null) + for v in "$hp" "$ht"; do + [ -z "$v" ] && continue + case "$v" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]) ;; + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; + [0-9][0-9][0-9][0-9]-W[0-9][0-9]) ;; + *) echo '{"result":[]}'; exit 0 ;; + esac + done + printf '{"result":' + $SCRIPTS/trafficctl-history.sh read-ifaces "$hp" "$ht" + printf '}' + ;; history_config_get) printf '{"result":' $SCRIPTS/trafficctl-history.sh status @@ -673,6 +694,10 @@ call) if [ -n "$val" ]; then uci set "trafficctl.history.enabled=$([ "$val" = "true" ] && echo 1 || echo 0)" fi + val=$(echo "$input" | jsonfilter -e '@.interfaces' 2>/dev/null) + if [ -n "$val" ]; then + uci set "trafficctl.history.interfaces=$([ "$val" = "true" ] && echo 1 || echo 0)" + fi tctl_commit # The keeper re-reads the intervals and the directory on every tick, # but it has to be RUNNING to do that — switching history on for the diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh index f4646da90..d90774e89 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-history.sh @@ -93,6 +93,7 @@ ERRFILE="/tmp/trafficctl_history.err" STAMP="/tmp/trafficctl_history.stamp" LOCKD="/tmp/trafficctl_history.lock.d" TOTALS_STATE="/tmp/trafficctl_totals.state" +IFCURSOR="/tmp/trafficctl_history.ifcursor" # Row layout, tab-separated: # key mac ip name rx tx tcp udp first_seen last_seen degraded @@ -211,6 +212,23 @@ hist_period_id() { hist_file() { printf '%s/%s.tsv\n' "$(hist_dir)" "$1"; } +# Interface totals live in their OWN file per period, not as an extra record +# type in the device file (#102). +# +# The issue proposed a type column. A separate file is better for the reason +# that matters here: hist_read emits every row it finds as a device, so an +# interface row sharing the file would be reported AS a device by any reader +# that predates the change — including the History tab as shipped in v1.22.0. +# A file the old reader never opens cannot be misread. +hist_iffile() { printf '%s/%s.if.tsv\n' "$(hist_dir)" "$1"; } + +# Per-interface accumulation can be turned off on its own: on a single-WAN +# router it is a handful of rows nobody reads, and the point of the feature is +# multi-WAN. +hist_ifaces_enabled() { + [ "$(uci -q get trafficctl.history.interfaces 2>/dev/null)" != "0" ] +} + hist_err_set() { printf '%s\t%s\n' "$(date +%s)" "$1" > "$ERRFILE" 2>/dev/null tctl_log config "history: $1" 2>/dev/null || true @@ -546,10 +564,220 @@ hist_flush() { printf '%s\n' "$now" > "$STAMP" 2>/dev/null hist_err_clear + # Only after the device file is safely in place. A failure here is not a + # failure of the flush: the interface series is additive, and losing one + # interval of it must not make the caller think the device totals did not + # land. + hist_if_flush || hist_err_set "interface totals could not be written" + + printf '{"ok":true,"period":"%s","devices":%s,"at":%s}\n' \ "$pid" "$(grep -vc '^#' "$file" 2>/dev/null || echo 0)" "$now" } +# ────────────────────────────────────────────────────────────────────────────── +# Interface totals (#102) +# +# A SECOND SERIES, not a breakdown of the device one, and the distinction is +# not pedantry: +# +# * these bytes are the kernel's own interface counters, read by +# trafficctl-ifaces.sh out of /proc/net/dev. They include the router's own +# traffic, broadcast, and everything per-device accounting cannot +# attribute, so they legitimately DISAGREE with the sum over devices. +# * under flow offloading they are the MORE trustworthy of the two, because +# they come from the driver rather than from conntrack — exactly inverted +# from the device series, where offload is what degrades accuracy. Hence +# hist_if_accuracy below, which must not inherit the device verdict. +# * they answer a question the device series cannot: how much went out of +# wan1 versus wan2 this month. A device's bytes carry no egress interface. +# +# rx/tx are relative to the INTERFACE, not to a client: rx on the WAN device +# is download, rx on br-lan is what the LAN sent upstream. Stored as read, +# with the role, so a consumer can label them rather than guess. +# ────────────────────────────────────────────────────────────────────────────── + +# Interfaces whose bytes are already counted somewhere else, and would +# double-count if recorded: +# enslaved — a bridge port; its traffic is in the bridge's own counters +# lo — loopback +# the IFB — a mirror of LAN ingress that the shaper creates, so every byte +# on it is a byte already counted on the device it mirrors +hist_if_skip() { + local dev="$1" enslaved="$2" ifb + [ "$dev" = "lo" ] && return 0 + [ "$enslaved" = "true" ] && return 0 + ifb=$(uci -q get trafficctl.main.shape_ifb 2>/dev/null) + [ -n "$ifb" ] && [ "$dev" = "$ifb" ] && return 0 + return 1 +} + +# What the interface series is worth. NOT the device verdict: an interface +# counter comes from the driver, so uncountered offload — the thing that makes +# the device totals a lower bound — does not touch it. Reporting "degraded" +# here would be its own kind of wrong. +hist_if_accuracy() { + printf 'exact\n' +} + +# Fold the interface counters into the current period, exactly as the device +# flush does: growth against a tmpfs cursor, and a live value below the cursor +# means the counter restarted rather than that traffic went backwards. +# +# An interface counter restarting is NOT an edge case here. It happens every +# time the link goes down, which on the multi-WAN box this feature is for is +# routine — so a flap costs one sampling interval instead of corrupting the +# month. +hist_if_flush() { + local dir pid file tmp now sample rc=0 + hist_ifaces_enabled || return 0 + dir=$(hist_dir) + pid=$(hist_period_id) + file=$(hist_iffile "$pid") + now=$(date +%s) + sample="/tmp/.trafficctl_history.ifsample.$$" + tmp="$dir/.history-if.$pid.$$" + # shellcheck disable=SC2064 + trap "rm -f '$sample' '$tmp'" EXIT INT TERM + + /usr/local/bin/trafficctl-ifaces.sh 2>/dev/null \ + | sed 's/},{/}\n{/g' > "$sample" + [ -s "$sample" ] || return 0 + + awk -v sample="$sample" -v cursor="$IFCURSOR" -v out="$tmp" -v store="$file" \ + -v now="$now" -v ifb="$(uci -q get trafficctl.main.shape_ifb 2>/dev/null)" ' + function num(line, key, re, seg) { + re = "\"" key "\"[ \t]*:[ \t]*" + if (!match(line, re)) return -1 + seg = substr(line, RSTART + RLENGTH) + if (!match(seg, /^[0-9]+/)) return -1 + return substr(seg, RSTART, RLENGTH) + 0 + } + function str(line, key, re, seg) { + re = "\"" key "\"[ \t]*:[ \t]*\"" + if (!match(line, re)) return "" + seg = substr(line, RSTART + RLENGTH) + if (!match(seg, /"/)) return "" + return substr(seg, 1, RSTART - 1) + } + BEGIN { + FS = "\t" + cf = cursor ".new" + printf "" > cf + while ((getline l < cursor) > 0) { + n = split(l, f, "\t") + if (n < 3) continue + crx[f[1]] = f[2] + 0; ctx[f[1]] = f[3] + 0 + seen[f[1]] = 1 + } + close(cursor) + while ((getline l < store) > 0) { + if (l ~ /^#/) continue + n = split(l, f, "\t") + if (n < 7) continue + d = f[1] + order[++norder] = d + role[d] = f[2]; label[d] = f[3] + rrx[d] = f[4] + 0; rtx[d] = f[5] + 0 + first[d] = f[6] + 0; last[d] = f[7] + 0 + have[d] = 1 + } + close(store) + + while ((getline line < sample) > 0) { + if (line !~ /"dev":"/) continue + d = str(line, "dev") + if (d == "") continue + if (d == "lo") continue + if (index(line, "\"enslaved\":true") > 0) continue + if (ifb != "" && d == ifb) continue + lrx = num(line, "rx_bytes"); if (lrx < 0) continue + ltx = num(line, "tx_bytes"); if (ltx < 0) continue + + drx = lrx; dtx = ltx + if (seen[d]) { + drx = 0; dtx = 0 + if (lrx >= crx[d]) drx = lrx - crx[d] + if (ltx >= ctx[d]) dtx = ltx - ctx[d] + } + if (!(d in have)) { + order[++norder] = d + have[d] = 1 + first[d] = now + rrx[d] = 0; rtx[d] = 0 + } + rrx[d] += drx; rtx[d] += dtx + r = str(line, "role"); if (r != "") role[d] = r + lb = str(line, "label"); if (lb != "") label[d] = lb + if (drx > 0 || dtx > 0) last[d] = now + else if (last[d] == 0) last[d] = now + ncrx[d] = lrx; nctx[d] = ltx + written[d] = 1 + } + close(sample) + + printf "# trafficctl-history-if 1\n" > out + for (i = 1; i <= norder; i++) { + d = order[i] + if (!(d in have) || emitted[d]) continue + emitted[d] = 1 + rl = role[d]; if (rl == "") rl = "?" + lb = label[d]; if (lb == "") lb = "-" + printf "%s\t%s\t%s\t%.0f\t%.0f\t%d\t%d\n", \ + d, rl, lb, rrx[d], rtx[d], first[d], last[d] > out + } + close(out) + + for (d in seen) if (!(d in written)) + printf "%s\t%.0f\t%.0f\n", d, crx[d], ctx[d] > cf + for (d in written) + printf "%s\t%.0f\t%.0f\n", d, ncrx[d], nctx[d] > cf + close(cf) + }' /dev/null || return 1 + mv "$IFCURSOR.new" "$IFCURSOR" 2>/dev/null || true + return 0 +} + +hist_if_read() { + local dir from to files f p + dir=$(hist_dir) + from="$1"; to="$2" + [ -z "$from" ] && from=$(hist_period_id) + [ -z "$to" ] && to="$from" + files="" + for f in "$dir"/*.if.tsv; do + [ -f "$f" ] || continue + p=$(basename "$f" .if.tsv) + [ "$p" \< "$from" ] && continue + [ "$to" \< "$p" ] && continue + files="$files $f" + done + # shellcheck disable=SC2086 # deliberate splitting over the file list + awk -F'\t' ' + /^#/ { next } + NF >= 7 { + d = $1 + if (!(d in seen)) { order[++n] = d; seen[d] = 1; first[d] = $6 } + role[d] = $2; label[d] = $3 + rx[d] += $4; tx[d] += $5 + if ($6 + 0 < first[d] + 0) first[d] = $6 + if ($7 + 0 > last[d] + 0) last[d] = $7 + } + END { + printf "[" + for (i = 1; i <= n; i++) { + d = order[i] + if (i > 1) printf "," + printf "{\"dev\":\"%s\",\"role\":\"%s\",\"label\":\"%s\",\"bytes_in\":%.0f,\"bytes_out\":%.0f,\"first_seen\":%d,\"last_seen\":%d}", \ + d, role[d], label[d], rx[d], tx[d], first[d], last[d] + } + printf "]\n" + }' $files /dev/null || printf '[]\n' +} + # ────────────────────────────────────────────────────────────────────────────── # read / periods # ────────────────────────────────────────────────────────────────────────────── @@ -563,6 +791,9 @@ hist_read() { files="" for f in "$dir"/*.tsv; do [ -f "$f" ] || continue + # The interface totals are a separate series in their own file; reading + # them here would report an interface as a device. + case "$f" in *.if.tsv) continue ;; esac p=$(basename "$f" .tsv) # String comparison is the right one here: every period id this script # writes is fixed-width and zero-padded, so lexical order IS @@ -614,6 +845,9 @@ hist_periods() { n=0 for f in "$dir"/*.tsv; do [ -f "$f" ] || continue + # Skip the interface companion: a period is listed once, by its device + # file, or "2026-10" and "2026-10.if" would both appear as periods. + case "$f" in *.if.tsv) continue ;; esac p=$(basename "$f" .tsv) [ "$n" -gt 0 ] && printf ',' printf '{"period":"%s","devices":%s,"bytes":%s}' \ @@ -633,13 +867,20 @@ hist_prune() { # The current period is never a retention candidate — it is still being # written to, and "keep 1" must not mean "delete the data you are # collecting right now". + # A PERIOD is the unit of retention, and a period is now two files: the + # device totals and the interface totals (#102). Listing *.tsv and + # stripping .tsv would turn "2026-10.if.tsv" into a period called + # "2026-10.if" — never equal to the current period id, so it would be + # pruned as if it were ancient history while its device file survived. + # Enumerate periods from the device files only, then drop both. # shellcheck disable=SC2046 # deliberate splitting: one word per period id set -- $(find "$dir" -name '*.tsv' -type f 2>/dev/null \ - | sed 's|.*/||; s|\.tsv$||' | grep -vxF "$pid" | sort) + | sed 's|.*/||' | grep -v '\.if\.tsv$' | sed 's|\.tsv$||' \ + | grep -vxF "$pid" | sort) total=$# while [ "$total" -gt "$keep" ] && [ "$#" -gt 0 ]; do - f="$dir/$1.tsv" - rm -f "$f" 2>/dev/null && dropped=$((dropped + 1)) + rm -f "$dir/$1.tsv" 2>/dev/null && dropped=$((dropped + 1)) + rm -f "$dir/$1.if.tsv" 2>/dev/null total=$((total - 1)) shift done @@ -686,10 +927,11 @@ hist_status() { fi w=false [ -d "$dir" ] && [ -w "$dir" ] && w=true - printf '{"enabled":%s,"dir":"%s","writable":%s,"period":"%s","period_kind":"%s","interval":%s,"sample_interval":%s,"keep":%s,"format":%s,"last_flush":%s,"accuracy":"%s","offload":"%s","error":"%s","error_at":%s}\n' \ + printf '{"enabled":%s,"dir":"%s","writable":%s,"period":"%s","period_kind":"%s","interval":%s,"sample_interval":%s,"keep":%s,"format":%s,"last_flush":%s,"accuracy":"%s","offload":"%s","interfaces":%s,"iface_accuracy":"%s","error":"%s","error_at":%s}\n' \ "$en" "$dir" "$w" "$pid" "$(hist_period_kind)" \ "$(hist_interval)" "$(hist_sample_interval)" "$(hist_keep)" "$FORMAT" "$last" \ "$(hist_accuracy)" "$(tctl_get_offload_mode 2>/dev/null)" \ + "$(hist_ifaces_enabled && echo true || echo false)" "$(hist_if_accuracy)" \ "$(printf '%s' "$err" | tr -d '\042\134')" "$errat" } @@ -743,11 +985,12 @@ case "$1" in # than a second copy of the rule that can drift away from it. validate-dir) hist_validate_dir "${2%/}" ;; read) hist_read "$2" "$3" ;; + read-ifaces) hist_if_read "$2" "$3" ;; periods) hist_periods ;; prune) hist_prune ;; status) hist_status ;; *) - printf 'Usage: %s {sample|flush|keeper|validate-dir |read [period [to]]|periods|prune|status}\n' "$0" >&2 + printf 'Usage: %s {sample|flush|keeper|validate-dir |read [period [to]]|read-ifaces [period [to]]|periods|prune|status}\n' "$0" >&2 exit 1 ;; esac diff --git a/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json b/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json index c4c5849b5..434445776 100644 --- a/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json +++ b/luci-app-trafficctl/root/usr/share/rpcd/acl.d/luci-app-trafficctl.json @@ -11,7 +11,7 @@ "shape_status", "config_get", "telegram_config_get", "logging_config_get", "newdevice_config_get", - "history_status", "history_read", "history_periods", + "history_status", "history_read", "history_read_ifaces", "history_periods", "history_config_get" ], "file": [ "read" ]