mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 09:54:06 +08:00
🤞 Sync 2026-10-29 22:46:41
This commit is contained in:
1 parent
ff521a5a6a
commit
8d20d896c0
78 files changed
+16387
-1210
No files matched your search
@@ -1,5 +1,6 @@
|
||||
|
||||
config config
|
||||
option prefer 'wwan0_1'
|
||||
option prefer ''
|
||||
option backend 'normal'
|
||||
|
||||
option mode 'daily'
|
||||
option vnstat_autoadd '1'
|
||||
@@ -0,0 +1,9 @@
|
||||
# Start vnStat tracking for interfaces as they come up (modems, PPPoE and
|
||||
# VPNs often only get their device name at this point).
|
||||
[ "$ACTION" = ifup ] || exit 0
|
||||
[ "$INTERFACE" = loopback ] && exit 0
|
||||
[ -x /usr/libexec/netstat-vnstat-track ] || exit 0
|
||||
|
||||
dev="$DEVICE"
|
||||
[ -n "$dev" ] || dev="$(ubus call network.interface."$INTERFACE" status 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)"
|
||||
[ -n "$dev" ] && /usr/libexec/netstat-vnstat-track "$dev"
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# First install / first boot: make sure the settings section exists and let
|
||||
# vnStat start tracking whatever interfaces are already up. On a fresh boot
|
||||
# netifd is not running yet; the iface hotplug hook adds devices as they
|
||||
# come up instead.
|
||||
|
||||
touch /etc/config/netstats
|
||||
uci -q get netstats.@config[0] >/dev/null || uci -q add netstats config >/dev/null
|
||||
uci -q get netstats.@config[0].backend >/dev/null || uci -q set netstats.@config[0].backend='normal'
|
||||
uci -q get netstats.@config[0].vnstat_autoadd >/dev/null || uci -q set netstats.@config[0].vnstat_autoadd='1'
|
||||
uci -q commit netstats
|
||||
|
||||
[ -x /usr/libexec/netstat-vnstat-track ] && /usr/libexec/netstat-vnstat-track
|
||||
|
||||
rm -f /tmp/luci-indexcache* /tmp/luci-netstat-vnstat-month
|
||||
exit 0
|
||||
@@ -1,6 +1,8 @@
|
||||
module("luci.controller.netstat", package.seeall)
|
||||
local nixio = require("nixio")
|
||||
local nfs = require("nixio.fs")
|
||||
local uci = require("luci.model.uci").cursor()
|
||||
local nsu = require("luci.netstat")
|
||||
|
||||
function index()
|
||||
entry({"admin", "tools"}, firstchild(), _("Tools"), 50).dependent = false
|
||||
@@ -34,19 +36,7 @@ function getNetdevStats()
|
||||
end
|
||||
end
|
||||
|
||||
-- Quick connectivity check
|
||||
local status = "Disconnected"
|
||||
for iface, data in pairs(stats) do
|
||||
if iface ~= "lo" and (data.rx > 0 or data.tx > 0) then
|
||||
status = "Connected"
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- ── IP detection ─────────────────────────────────────────────────────────
|
||||
-- Strategy: try a single fast HTTP call first (2 s timeout), then fall
|
||||
-- back to local ubus/ifstatus which is instant. The old code tried 11
|
||||
-- commands with 4 s timeouts each – worst-case 44 s of blocking.
|
||||
local status = #nsu.default_route_devices() > 0 and "Connected" or "Disconnected"
|
||||
|
||||
local function read_cmd(cmd)
|
||||
local p = io.popen(cmd)
|
||||
@@ -58,38 +48,11 @@ function getNetdevStats()
|
||||
return line ~= "" and line or nil
|
||||
end
|
||||
|
||||
-- ── Preferred interface resolution ──────────────────────────────────────
|
||||
-- The "Preferred Interface" setting may hold either a real netdev name
|
||||
-- (e.g. "eth2", "wwan0_1") or a logical/config interface name (e.g.
|
||||
-- "modem", "wan") that has no matching key in /proc/net/dev. In the
|
||||
-- latter case resolve it to its actual l3_device via ubus so traffic
|
||||
-- stats can be found.
|
||||
local prefer_iface = ""
|
||||
do
|
||||
local prefer = uci:get_first("netstats", "config", "prefer") or ""
|
||||
-- Only allow safe characters before using it in a shell command.
|
||||
if prefer ~= "" and prefer:match("^[%w%-%._]+$") then
|
||||
if stats[prefer] then
|
||||
prefer_iface = prefer
|
||||
else
|
||||
local l3 = read_cmd(
|
||||
"ubus call network.interface." .. prefer ..
|
||||
" status 2>/dev/null | jsonfilter -e '@.l3_device'"
|
||||
)
|
||||
if l3 and stats[l3] then
|
||||
prefer_iface = l3
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Nothing configured (or unresolvable): use the default-route device
|
||||
-- so idle interfaces named "wan"/"eth1" don't win over the real uplink.
|
||||
if prefer_iface == "" then
|
||||
local dev = read_cmd("ip route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i==\"dev\"){print $(i+1); exit}}'")
|
||||
if dev and dev:match("^[%w%-%._]+$") and stats[dev] then
|
||||
prefer_iface = dev
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ── Tracked device ──────────────────────────────────────────────────────
|
||||
-- The "Preferred Interface" setting (netdev or logical name such as
|
||||
-- "wan"), otherwise the default-route device. Both are resolved
|
||||
-- generically; no interface names are assumed.
|
||||
local prefer_iface = nsu.wan_device(stats) or ""
|
||||
|
||||
local function is_valid_ip(v)
|
||||
if not v then return false end
|
||||
@@ -103,6 +66,7 @@ function getNetdevStats()
|
||||
end
|
||||
|
||||
local ip = "N/A"
|
||||
local ip_source = "none"
|
||||
|
||||
-- Determine whether the caller wants a public IP (slow, requires network)
|
||||
-- or just the fast local WAN address. The JS frontend sends ?fast=1 for
|
||||
@@ -112,30 +76,29 @@ function getNetdevStats()
|
||||
-- 1. Try public-IP APIs (only when not in fast mode)
|
||||
if want_public then
|
||||
local public_cmds = {
|
||||
"curl -fsS --max-time 1 'https://api.ipify.org' 2>/dev/null",
|
||||
"curl -fsS --max-time 1 'http://api.ipify.org' 2>/dev/null",
|
||||
"uclient-fetch -qO- --timeout=1 'https://api.ipify.org' 2>/dev/null",
|
||||
"curl -fsS --max-time 2 'https://api.ipify.org' 2>/dev/null",
|
||||
"curl -fsS --max-time 2 'http://api.ipify.org' 2>/dev/null",
|
||||
"uclient-fetch -qO- --timeout=2 'https://api.ipify.org' 2>/dev/null",
|
||||
}
|
||||
for _, cmd in ipairs(public_cmds) do
|
||||
local v = read_cmd(cmd)
|
||||
if is_valid_ip(v) then ip = v; break end
|
||||
if is_valid_ip(v) then ip = v; ip_source = "public"; break end
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Fall back to local WAN address (instant, no network needed)
|
||||
if ip == "N/A" then
|
||||
local local_cmds = {
|
||||
-- wan IPv4
|
||||
"ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@[\"ipv4-address\"][0].address'",
|
||||
"ifstatus wan 2>/dev/null | jsonfilter -e '@[\"ipv4-address\"][0].address'",
|
||||
-- wan IPv6
|
||||
"ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@[\"ipv6-address\"][0].address'",
|
||||
"ubus call network.interface.wan6 status 2>/dev/null | jsonfilter -e '@[\"ipv6-address\"][0].address'",
|
||||
}
|
||||
for _, cmd in ipairs(local_cmds) do
|
||||
local v = read_cmd(cmd)
|
||||
if is_valid_ip(v) then ip = v; break end
|
||||
end
|
||||
-- 2. Fall back to the local address of the tracked device
|
||||
if ip == "N/A" and prefer_iface ~= "" then
|
||||
local v = nsu.device_ipaddr(prefer_iface)
|
||||
if is_valid_ip(v) then ip = v; ip_source = "local" end
|
||||
end
|
||||
|
||||
-- ── vnStat totals (only with the vnStat backend) ────────────────────────
|
||||
-- Live rates always come from /proc/net/dev; the vnStat backend swaps the
|
||||
-- "downloaded/uploaded" boxes to this month's totals from its database.
|
||||
local vnstat_total = nil
|
||||
if prefer_iface ~= "" and
|
||||
(uci:get_first("netstats", "config", "backend") or "normal") == "vnstat" then
|
||||
vnstat_total = nsu.vnstat_month_total(prefer_iface)
|
||||
end
|
||||
|
||||
-- ── System uptime ─────────────────────────────────────────────────────────
|
||||
@@ -199,9 +162,7 @@ function getNetdevStats()
|
||||
|
||||
-- ── Disk space (root filesystem) ──────────────────────────────────────────
|
||||
local disk_pct, disk_used_mb, disk_total_mb = 0, 0, 0
|
||||
-- Use POSIX output (-P): busybox df wraps long device names onto a second
|
||||
-- line without it, which breaks the NR==2 parse (e.g. LXC root device).
|
||||
local df = read_cmd("df -kP / 2>/dev/null | awk 'NR==2{print $2,$3}'")
|
||||
local df = read_cmd("df -k / 2>/dev/null | awk 'NR==2{print $2,$3}'")
|
||||
if df then
|
||||
local dtotal, dused = df:match("^(%d+)%s+(%d+)$")
|
||||
dtotal = tonumber(dtotal) or 0
|
||||
@@ -215,43 +176,9 @@ function getNetdevStats()
|
||||
|
||||
-- ── CPU temperature ───────────────────────────────────────────────────────
|
||||
local cpu_temp = nil
|
||||
|
||||
-- Preferred source: CPU-specific hwmon sensors (k10temp/coretemp/zenpower/
|
||||
-- ...). ACPI thermal zones can be absent or bogus (e.g. inside LXC
|
||||
-- containers the host ACPI zone is not the CPU sensor), while hwmon
|
||||
-- reports the real package temperature on x86.
|
||||
local cpu_hwmon = { k10temp = true, coretemp = true, zenpower = true, cpu_thermal = true, soc_thermal = true }
|
||||
if nixio.fs.access("/sys/class/hwmon") then
|
||||
for h in nixio.fs.dir("/sys/class/hwmon") do
|
||||
local base = "/sys/class/hwmon/" .. h
|
||||
local nf = io.open(base .. "/name", "r")
|
||||
local name = nf and nf:read("*l") or nil
|
||||
if nf then nf:close() end
|
||||
if name and cpu_hwmon[name] then
|
||||
for entry in nixio.fs.dir(base) do
|
||||
if entry:match("^temp%d+_input$") then
|
||||
local sf = io.open(base .. "/" .. entry, "r")
|
||||
local val = sf and sf:read("*l") or nil
|
||||
if sf then sf:close() end
|
||||
local t = tonumber(val)
|
||||
if t and t > 0 then
|
||||
if t > 1000 then t = t / 1000 end
|
||||
t = math.floor(t + 0.5)
|
||||
if cpu_temp == nil or t > cpu_temp then
|
||||
cpu_temp = t
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback: scan all available thermal zones and pick the highest valid
|
||||
-- reading (avoids hardcoded zone numbers which vary per device)
|
||||
for zone = 0, 9 do
|
||||
if cpu_temp ~= nil then break end
|
||||
local path = "/sys/class/thermal/thermal_zone" .. zone .. "/temp"
|
||||
-- Scan all available thermal zones and pick the highest valid reading
|
||||
-- (avoids hardcoded zone numbers which vary per device)
|
||||
for path in (nfs.glob("/sys/class/thermal/thermal_zone*/temp") or function() end) do
|
||||
local tf = io.open(path, "r")
|
||||
if tf then
|
||||
local val = tf:read("*l")
|
||||
@@ -270,8 +197,10 @@ function getNetdevStats()
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ stats = stats, ip = ip, status = status,
|
||||
luci.http.write_json({ stats = stats, ip = ip, ip_source = ip_source,
|
||||
status = status,
|
||||
prefer_iface = prefer_iface,
|
||||
vnstat_total = vnstat_total,
|
||||
uptime = uptime_sec,
|
||||
cpu_pct = cpu_pct,
|
||||
cpu_temp = cpu_temp,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
local net = require "luci.model.network"
|
||||
local sys = require "luci.sys"
|
||||
local http = require "luci.http"
|
||||
local nsu = require "luci.netstat"
|
||||
|
||||
local m = Map("netstats", translate("Netstat"),
|
||||
translate("Configure traffic monitoring and view usage history from a single page.")
|
||||
@@ -22,8 +23,10 @@ if active_tab == "settings" then
|
||||
backend:value("normal", translate("Normal (Real-time)"))
|
||||
backend:value("vnstat", translate("vnStat (Historical, Delayed)"))
|
||||
backend.description = translate(
|
||||
"Normal reads live counters from /proc/net/dev. " ..
|
||||
"vnStat reads the vnStat database which updates on its own schedule."
|
||||
"Live download/upload rates always come from /proc/net/dev. " ..
|
||||
"With Normal the downloaded/uploaded totals count since boot; " ..
|
||||
"with vnStat they show this month's totals from the vnStat database, " ..
|
||||
"which vnStat updates every few minutes."
|
||||
)
|
||||
|
||||
-- View mode
|
||||
@@ -36,33 +39,66 @@ if active_tab == "settings" then
|
||||
-- Interface preference
|
||||
local iface = s:option(ListValue, "prefer", translate("Preferred Interface"))
|
||||
iface.description = translate(
|
||||
"WAN interface to track. Leave blank for auto-detection.<br>" ..
|
||||
"<strong>Tip:</strong> If your WAN uses a dynamic name (e.g. wwan0_1) " ..
|
||||
"it will be detected automatically when left blank."
|
||||
"Interface to track. Leave on auto-detection to follow the device " ..
|
||||
"that holds the default route, whatever it is called on this router.<br>" ..
|
||||
"Logical interfaces (e.g. wan) are resolved to their current device."
|
||||
)
|
||||
iface:value("", translate("Auto detect"))
|
||||
|
||||
local netm = net.init()
|
||||
for _, dev in ipairs(netm:get_interfaces()) do
|
||||
local name = dev:shortname()
|
||||
if name and not name:match("^lo$") and not name:match("^br%-") then
|
||||
iface:value(name)
|
||||
-- Logical netifd interfaces, live netdevs and anything vnStat already
|
||||
-- has history for. Nothing is hard-coded.
|
||||
local seen = { [""] = true, lo = true, loopback = true }
|
||||
local function add_choice(name, label)
|
||||
if name and not seen[name] and nsu.safe_name(name) then
|
||||
seen[name] = true
|
||||
iface:value(name, label or name)
|
||||
end
|
||||
end
|
||||
|
||||
local netm = net.init()
|
||||
for _, n in ipairs(netm:get_networks()) do
|
||||
add_choice(n:name(), n:name() .. " (" .. translate("logical interface") .. ")")
|
||||
end
|
||||
for _, dev in ipairs(netm:get_interfaces()) do
|
||||
add_choice(dev:shortname())
|
||||
end
|
||||
for _, name in ipairs((nsu.db_interfaces())) do
|
||||
add_choice(name, name .. " (" .. translate("vnStat history") .. ")")
|
||||
end
|
||||
|
||||
-- Data retention info
|
||||
local info = s:option(DummyValue, "_info", translate("vnStat Database"))
|
||||
info.rawhtml = true
|
||||
info.cfgvalue = function()
|
||||
-- Show current database size if available
|
||||
local size_out = sys.exec("du -sh /etc/vnstat/ 2>/dev/null | cut -f1") or ""
|
||||
if not nsu.vnstat_installed() then
|
||||
return '<span style="font-size:12px;color:#b91c1c;">' ..
|
||||
translate("vnStat is not installed. Install the vnstat2 package.") ..
|
||||
'</span>'
|
||||
end
|
||||
|
||||
local dir = nsu.vnstat_dbdir()
|
||||
local size_out = sys.exec("du -sh '" .. dir:gsub("'", "") .. "' 2>/dev/null | cut -f1") or ""
|
||||
size_out = size_out:match("^%s*(.-)%s*$") or ""
|
||||
local db_size = size_out ~= "" and (" — " .. size_out) or ""
|
||||
return string.format(
|
||||
'<span style="font-size:12px;color:#6b7280;">%s%s</span>',
|
||||
translate("Location: /etc/vnstat/"),
|
||||
db_size
|
||||
|
||||
local tracked = table.concat((nsu.db_interfaces()), ", ")
|
||||
if tracked == "" then tracked = translate("none yet") end
|
||||
|
||||
local html = string.format(
|
||||
'<span style="font-size:12px;opacity:0.75;">%s %s%s<br>%s %s</span>',
|
||||
translate("Location:"), dir, db_size,
|
||||
translate("Tracked interfaces:"), tracked
|
||||
)
|
||||
|
||||
-- /var is a tmpfs on OpenWrt, so the default location loses history
|
||||
-- on every reboot.
|
||||
if dir:match("^/var/") or dir:match("^/tmp/") or dir == "/var" or dir == "/tmp" then
|
||||
html = html .. '<br><span style="font-size:12px;color:#b45309;">' ..
|
||||
translate("This directory is in RAM, so history is lost on reboot. " ..
|
||||
"Set DatabaseDir in /etc/vnstat.conf to persistent storage to keep it.") ..
|
||||
'</span>'
|
||||
end
|
||||
return html
|
||||
end
|
||||
|
||||
-- Reset button with inline JS confirmation
|
||||
@@ -93,19 +129,16 @@ if active_tab == "settings" then
|
||||
end
|
||||
|
||||
function reset.write(self, section)
|
||||
sys.call("/etc/init.d/vnstat stop 2>/dev/null")
|
||||
sys.call("rm -f /etc/vnstat/* 2>/dev/null")
|
||||
|
||||
-- Re-initialise only interfaces that currently exist
|
||||
local netm2 = net.init()
|
||||
for _, dev in ipairs(netm2:get_interfaces()) do
|
||||
local name = dev:shortname()
|
||||
if name and not name:match("^lo$") and not name:match("^br%-") then
|
||||
sys.call("vnstat -u -i " .. name .. " >/dev/null 2>&1")
|
||||
end
|
||||
-- vnStat 2 keeps everything in one SQLite database, so drop and
|
||||
-- re-create each interface's entry instead of deleting files.
|
||||
local ifaces = nsu.db_interfaces()
|
||||
sys.call("/etc/init.d/vnstat stop >/dev/null 2>&1")
|
||||
for _, name in ipairs(ifaces) do
|
||||
sys.call(nsu.VNSTAT .. " --remove --force -i " .. name .. " >/dev/null 2>&1")
|
||||
sys.call(nsu.VNSTAT .. " --add --force -i " .. name .. " >/dev/null 2>&1")
|
||||
end
|
||||
|
||||
sys.call("/etc/init.d/vnstat start 2>/dev/null")
|
||||
sys.call("rm -f /tmp/luci-netstat-vnstat-month")
|
||||
sys.call("/etc/init.d/vnstat start >/dev/null 2>&1")
|
||||
m.message = translate("vnStat database has been reset successfully for all interfaces.")
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
-- Shared helpers for luci-app-netstat.
|
||||
--
|
||||
-- Everything here relies only on what every OpenWrt router has: the vnStat 2
|
||||
-- CLI, procfs/sysfs and netifd over ubus. There are no board-specific
|
||||
-- interface names or device paths; the uplink is found from the routing
|
||||
-- table and history comes from whatever vnStat has in its database.
|
||||
|
||||
local nixio = require "nixio"
|
||||
local fs = require "nixio.fs"
|
||||
local util = require "luci.util"
|
||||
local jsonc = require "luci.jsonc"
|
||||
local uci = require "luci.model.uci"
|
||||
|
||||
local M = {}
|
||||
|
||||
M.VNSTAT = "/usr/bin/vnstat"
|
||||
|
||||
-- Netdev and logical interface names are used in shell commands, so only
|
||||
-- allow the characters Linux and netifd actually use.
|
||||
function M.safe_name(name)
|
||||
return type(name) == "string" and name ~= "" and name:match("^[%w%-%._]+$") ~= nil
|
||||
end
|
||||
|
||||
function M.netdev_exists(name)
|
||||
return M.safe_name(name) and fs.access("/sys/class/net/" .. name) and true or false
|
||||
end
|
||||
|
||||
function M.vnstat_installed()
|
||||
return fs.access(M.VNSTAT, "x") and true or false
|
||||
end
|
||||
|
||||
-- Interfaces present in the vnStat database, one per line from
|
||||
-- "vnstat --dbiflist 1". Returns a sorted list and a lookup set.
|
||||
function M.db_interfaces()
|
||||
local list, set = {}, {}
|
||||
if not M.vnstat_installed() then return list, set end
|
||||
local out = util.exec(M.VNSTAT .. " --dbiflist 1 2>/dev/null") or ""
|
||||
for name in out:gmatch("[^\n]+") do
|
||||
name = name:match("^%s*(.-)%s*$")
|
||||
if M.safe_name(name) and not set[name] then
|
||||
set[name] = true
|
||||
list[#list + 1] = name
|
||||
end
|
||||
end
|
||||
table.sort(list)
|
||||
return list, set
|
||||
end
|
||||
|
||||
-- Run "vnstat --json <mode> <limit> [-i iface]" and return the raw text if it
|
||||
-- looks like JSON, or nil (vnStat missing, interface not in the database, ...).
|
||||
function M.vnstat_json_raw(mode, limit, iface)
|
||||
if not M.vnstat_installed() then return nil end
|
||||
local cmd = M.VNSTAT .. " --json " .. mode .. " " .. tostring(tonumber(limit) or 0)
|
||||
if iface then
|
||||
if not M.safe_name(iface) then return nil end
|
||||
cmd = cmd .. " -i " .. iface
|
||||
end
|
||||
local out = util.exec(cmd .. " 2>/dev/null") or ""
|
||||
if not out:match("^%s*{") then return nil end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Devices that carry a default route, lowest metric first (IPv4, then IPv6).
|
||||
-- Read straight from the kernel so it works whatever the uplink is called
|
||||
-- (pppoe-wan, wwan0, eth1, wg0, ...).
|
||||
function M.default_route_devices()
|
||||
local found, seen = {}, {}
|
||||
local function add(dev, metric)
|
||||
if dev and dev ~= "lo" and not seen[dev] then
|
||||
seen[dev] = true
|
||||
found[#found + 1] = { dev = dev, metric = metric or 0 }
|
||||
end
|
||||
end
|
||||
|
||||
local f = io.open("/proc/net/route", "r")
|
||||
if f then
|
||||
local v4 = {}
|
||||
for line in f:lines() do
|
||||
local fld = {}
|
||||
for w in line:gmatch("%S+") do fld[#fld + 1] = w end
|
||||
-- Iface Destination Gateway Flags RefCnt Use Metric Mask ...
|
||||
if fld[2] == "00000000" and fld[8] == "00000000" then
|
||||
v4[#v4 + 1] = { dev = fld[1], metric = tonumber(fld[7]) or 0 }
|
||||
end
|
||||
end
|
||||
f:close()
|
||||
table.sort(v4, function(a, b) return a.metric < b.metric end)
|
||||
for _, r in ipairs(v4) do add(r.dev, r.metric) end
|
||||
end
|
||||
|
||||
f = io.open("/proc/net/ipv6_route", "r")
|
||||
if f then
|
||||
local v6 = {}
|
||||
for line in f:lines() do
|
||||
local fld = {}
|
||||
for w in line:gmatch("%S+") do fld[#fld + 1] = w end
|
||||
-- dest prefixlen src srcprefixlen nexthop metric refcnt use flags dev
|
||||
if fld[1] == "00000000000000000000000000000000" and fld[2] == "00" and fld[10] then
|
||||
v6[#v6 + 1] = { dev = fld[10], metric = tonumber(fld[6], 16) or 0 }
|
||||
end
|
||||
end
|
||||
f:close()
|
||||
table.sort(v6, function(a, b) return a.metric < b.metric end)
|
||||
for _, r in ipairs(v6) do add(r.dev, r.metric) end
|
||||
end
|
||||
|
||||
local devs = {}
|
||||
for _, r in ipairs(found) do devs[#devs + 1] = r.dev end
|
||||
return devs
|
||||
end
|
||||
|
||||
-- With a "known" set, a device is only accepted if it is in that set (e.g.
|
||||
-- the vnStat database); otherwise it has to exist in /sys/class/net.
|
||||
local function accept(dev, known)
|
||||
if not M.safe_name(dev) then return nil end
|
||||
if known then return known[dev] and dev or nil end
|
||||
return M.netdev_exists(dev) and dev or nil
|
||||
end
|
||||
|
||||
-- Accepts a logical netifd interface name ("wan", "modem") or a netdev name
|
||||
-- ("eth1", "wwan0") and returns the matching netdev, or nil. Logical names
|
||||
-- win: on DSA routers the WAN port netdev is often also called "wan", but
|
||||
-- the logical interface's L3 device (e.g. pppoe-wan) is the real uplink.
|
||||
function M.resolve_device(name, known)
|
||||
if not M.safe_name(name) then return nil end
|
||||
local st = util.ubus("network.interface." .. name, "status", {})
|
||||
if type(st) == "table" then
|
||||
local dev = accept(st.l3_device, known) or accept(st.device, known)
|
||||
if dev then return dev end
|
||||
end
|
||||
return accept(name, known)
|
||||
end
|
||||
|
||||
function M.preferred_name()
|
||||
local c = uci.cursor()
|
||||
return c:get_first("netstats", "config", "prefer") or ""
|
||||
end
|
||||
|
||||
-- The device to show: the "Preferred Interface" setting if it resolves,
|
||||
-- otherwise the default-route device.
|
||||
function M.wan_device(known)
|
||||
local dev = M.resolve_device(M.preferred_name(), known)
|
||||
if dev then return dev end
|
||||
for _, d in ipairs(M.default_route_devices()) do
|
||||
dev = accept(d, known)
|
||||
if dev then return dev end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function first_addr(list)
|
||||
if type(list) == "table" and type(list[1]) == "table" then
|
||||
return list[1].address
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Local address of a device: netifd's view first, then the kernel's.
|
||||
function M.device_ipaddr(dev)
|
||||
if not M.safe_name(dev) then return nil end
|
||||
|
||||
local dump = util.ubus("network.interface", "dump", {})
|
||||
if type(dump) == "table" and type(dump.interface) == "table" then
|
||||
for _, key in ipairs({ "ipv4-address", "ipv6-address" }) do
|
||||
for _, ifc in ipairs(dump.interface) do
|
||||
if ifc.up and ifc.l3_device == dev then
|
||||
local a = first_addr(ifc[key])
|
||||
if a then return a end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local addrs = nixio.getifaddrs() or {}
|
||||
for _, fam in ipairs({ "inet", "inet6" }) do
|
||||
for _, a in ipairs(addrs) do
|
||||
if a.name == dev and a.family == fam and a.addr
|
||||
and not a.addr:match("^[Ff][Ee]80:") then
|
||||
return a.addr
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- This month's rx/tx bytes for a device from the vnStat database, cached in
|
||||
-- /tmp for a minute because the status widget polls every two seconds and
|
||||
-- vnStat only writes to its database every few minutes anyway.
|
||||
local CACHE = "/tmp/luci-netstat-vnstat-month"
|
||||
|
||||
function M.vnstat_month_total(dev)
|
||||
if not M.safe_name(dev) then return nil end
|
||||
|
||||
local now = os.time()
|
||||
local mtime = fs.stat(CACHE, "mtime")
|
||||
if mtime and now - mtime < 60 and now >= mtime then
|
||||
local c = fs.readfile(CACHE) or ""
|
||||
local cdev, rx, tx = c:match("^(%S+) (%d+) (%d+)")
|
||||
if cdev == dev then
|
||||
return { rx = tonumber(rx), tx = tonumber(tx) }
|
||||
end
|
||||
end
|
||||
|
||||
local raw = M.vnstat_json_raw("m", 1, dev)
|
||||
local data = raw and jsonc.parse(raw)
|
||||
if type(data) ~= "table" or type(data.interfaces) ~= "table" then return nil end
|
||||
local ifc = data.interfaces[1]
|
||||
if type(ifc) ~= "table" or type(ifc.traffic) ~= "table" then return nil end
|
||||
|
||||
-- jsonversion 1 (vnStat 1.x) used plural keys and KiB units.
|
||||
local legacy = tostring(data.jsonversion) == "1"
|
||||
local months = ifc.traffic[legacy and "months" or "month"]
|
||||
local scale = legacy and 1024 or 1
|
||||
|
||||
local rx, tx = 0, 0
|
||||
local last = type(months) == "table" and months[#months]
|
||||
if type(last) == "table" and type(last.date) == "table" then
|
||||
local t = os.date("*t", now)
|
||||
if tonumber(last.date.year) == t.year and tonumber(last.date.month) == t.month then
|
||||
rx = (tonumber(last.rx) or 0) * scale
|
||||
tx = (tonumber(last.tx) or 0) * scale
|
||||
end
|
||||
end
|
||||
|
||||
fs.writefile(CACHE, string.format("%s %.0f %.0f\n", dev, rx, tx))
|
||||
return { rx = rx, tx = tx }
|
||||
end
|
||||
|
||||
-- DatabaseDir from /etc/vnstat.conf, or vnStat's built-in default.
|
||||
function M.vnstat_dbdir()
|
||||
local conf = fs.readfile("/etc/vnstat.conf") or ""
|
||||
for line in conf:gmatch("[^\n]+") do
|
||||
local dir = line:match("^%s*DatabaseDir%s+\"([^\"]+)\"")
|
||||
if dir then return dir end
|
||||
end
|
||||
return "/var/lib/vnstat"
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="netstat-about-card">
|
||||
<h3 style="margin:0 0 4px;"><%:LuCI Netstat%></h3>
|
||||
<p style="margin:0 0 16px;font-size:13px;color:#6b7280;">
|
||||
<strong class="netstat-about-title"><%:LuCI Netstat%></strong>
|
||||
<p class="netstat-about-sub">
|
||||
<%:Frontend for traffic monitoring and usage charts on OpenWrt.%>
|
||||
</p>
|
||||
|
||||
@@ -40,22 +40,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0 12px;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;">
|
||||
<hr class="netstat-about-sep">
|
||||
<p class="netstat-about-foot">
|
||||
<%:Made with%> ♥ <%:for the OpenWrt community%>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.netstat-about-card {
|
||||
border: 1px solid #dfe3eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 20px 22px;
|
||||
max-width: 520px;
|
||||
}
|
||||
/* Layout only: colours and backgrounds come from the active LuCI theme. */
|
||||
.netstat-about-card { max-width: 520px; }
|
||||
.netstat-about-title { display: block; margin: 0 0 4px; font-size: 15px; }
|
||||
.netstat-about-sub { margin: 0 0 16px; font-size: 13px; opacity: 0.7; }
|
||||
.netstat-about-sep { border: none; border-top: 1px solid rgba(128, 128, 128, 0.3); margin: 16px 0 12px; }
|
||||
.netstat-about-foot { margin: 0; font-size: 12px; opacity: 0.7; text-align: center; }
|
||||
|
||||
.netstat-about-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
.netstat-about-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.netstat-about-row {
|
||||
display: flex;
|
||||
@@ -68,16 +67,10 @@
|
||||
.netstat-about-label {
|
||||
min-width: 110px;
|
||||
flex-shrink: 0;
|
||||
color: #6b7280;
|
||||
opacity: 0.7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.netstat-about-row a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
.netstat-about-row a:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.netstat-about-row { flex-direction: column; gap: 2px; }
|
||||
.netstat-about-label { min-width: unset; }
|
||||
|
||||
@@ -15,112 +15,13 @@ local tabs_def = {
|
||||
|
||||
%>
|
||||
|
||||
<style>
|
||||
.netstat-tab-wrap { margin: 4px 0 14px; }
|
||||
|
||||
.netstat-tabmenu { display:flex; gap:4px; margin:0; padding:0; list-style:none; flex-wrap:wrap; }
|
||||
|
||||
.netstat-tabmenu li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #cfd6e2;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: #e6ebf2;
|
||||
color: #2f3b52;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.netstat-tabmenu li a:hover:not(.active-link) {
|
||||
background: #d8e2f0;
|
||||
}
|
||||
|
||||
.netstat-tabmenu li.active a,
|
||||
.netstat-tabmenu li a.active-link {
|
||||
background: #f7f9fc;
|
||||
border-color: #cfd6e2;
|
||||
border-bottom-color: #f7f9fc;
|
||||
color: #3659d9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.netstat-tab-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.netstat-tabmenu li.active .netstat-tab-dot { opacity: 1; }
|
||||
|
||||
.netstat-tab-divider {
|
||||
border: none;
|
||||
border-top: 1px solid #cfd6e2;
|
||||
margin-top: -1px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
<% if active_tab == "settings" then %>
|
||||
.cbi-map > .cbi-section:last-of-type {
|
||||
border: 1px solid #dfe3eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
<% end %>
|
||||
|
||||
</style>
|
||||
|
||||
<style id="netstat-dark-style"></style>
|
||||
<script>
|
||||
(function () {
|
||||
var isDark = localStorage.getItem('luci-theme') === 'dark'
|
||||
|| document.body.classList.contains('dark-theme');
|
||||
window._netstatDark = isDark;
|
||||
if (!isDark) return;
|
||||
document.getElementById('netstat-dark-style').textContent = [
|
||||
'.netstat-tabmenu li a{background:#2a2d32!important;border-color:#4b5563!important;color:#d1d5db!important}',
|
||||
'.netstat-tabmenu li.active a{background:#1a1d20!important;border-color:#4b5563!important;border-bottom-color:#1a1d20!important;color:#60a5fa!important}',
|
||||
'.netstat-tab-divider{border-top-color:#4b5563!important}',
|
||||
'.netstat-dashboard{background:#1a1d20!important;border-color:#374151!important;color:#f3f4f6!important}',
|
||||
'.netstat-summary{background:#111315!important;border-color:#374151!important;color:#f3f4f6!important}',
|
||||
'.netstat-chart-wrap{background:#1a1d20!important;border-color:#374151!important}',
|
||||
'.netstat-chart-header{background:#1a1d20!important}',
|
||||
'.netstat-chart-title{color:#f3f4f6!important}',
|
||||
'.netstat-chart-badge{background:#1e3a5f!important;color:#93c5fd!important}',
|
||||
'.netstat-summary-label{color:#9ca3af!important}',
|
||||
'.netstat-summary-item{color:#f3f4f6!important}',
|
||||
'.netstat-empty{color:#9ca3af!important}',
|
||||
'.netstat-hint{color:#6b7280!important}',
|
||||
'.netstat-about-card{background:#1a1d20!important;border-color:#374151!important;color:#f3f4f6!important}',
|
||||
'.netstat-about-card h3{color:#f3f4f6!important}',
|
||||
'.netstat-about-card hr{border-top-color:#374151!important}',
|
||||
'.netstat-about-label{color:#9ca3af!important}',
|
||||
'.netstat-about-row a{color:#60a5fa!important}',
|
||||
'.cbi-map>.cbi-section:last-of-type{background:#1a1d20!important;border-color:#374151!important}'
|
||||
].join('');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="netstat-tab-wrap">
|
||||
<ul class="netstat-tabmenu">
|
||||
<% for _, tab in ipairs(tabs_def) do %>
|
||||
<li class="<%= active_tab == tab.id and "active" or "" %>">
|
||||
<a href="<%= tab_url(tab.id) %>">
|
||||
<span class="netstat-tab-dot"></span>
|
||||
<%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<hr class="netstat-tab-divider" />
|
||||
</div>
|
||||
<%# Standard LuCI tab markup, so every theme styles it like its own tabs. %>
|
||||
<ul class="cbi-tabmenu">
|
||||
<% for _, tab in ipairs(tabs_def) do %>
|
||||
<li class="<%= active_tab == tab.id and "cbi-tab" or "cbi-tab-disabled" %>">
|
||||
<a href="<%= tab_url(tab.id) %>"><%= tab.label %></a>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<input type="hidden" name="tab" value="<%=active_tab%>" />
|
||||
@@ -1,214 +1,116 @@
|
||||
<%
|
||||
local sys = require "luci.sys"
|
||||
local http = require "luci.http"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local http = require "luci.http"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local jsonc = require "luci.jsonc"
|
||||
local nsu = require "luci.netstat"
|
||||
|
||||
local function to_bytes(value, unit)
|
||||
local m = { KiB=1024, MiB=1048576, GiB=1073741824, TiB=1099511627776 }
|
||||
return tonumber(value) * (m[unit] or 1)
|
||||
end
|
||||
local have_vnstat = nsu.vnstat_installed()
|
||||
local ifaces, db = nsu.db_interfaces()
|
||||
local saved_mode = uci:get("netstats", "@config[0]", "mode") or "daily"
|
||||
|
||||
local function format_date(date_str)
|
||||
local months = {"Jan","Feb","Mar","Apr","May","Jun",
|
||||
"Jul","Aug","Sep","Oct","Nov","Dec"}
|
||||
local mo, d, y = date_str:match("(%d%d)/(%d%d)/(%d%d)")
|
||||
if mo and d and y then
|
||||
return string.format("%02d %s %02d", tonumber(d), months[tonumber(mo)], tonumber(y))
|
||||
end
|
||||
return date_str
|
||||
end
|
||||
-- Requested interface, then the Settings choice / default-route device, as
|
||||
-- long as vnStat has a database entry for it; else the first one it has.
|
||||
local cur_iface = nsu.resolve_device(http.formvalue("iface"), db)
|
||||
or nsu.wan_device(db)
|
||||
or ifaces[1] or ""
|
||||
|
||||
local function today_label() return os.date("%d %b %y") end
|
||||
local function curmonth_label() return os.date("%b '%y") end
|
||||
|
||||
local function ensure_today(stats)
|
||||
local lbl = today_label()
|
||||
for _, s in ipairs(stats) do if s.date == lbl then return stats end end
|
||||
table.insert(stats, { date=lbl, rx=0, tx=0 })
|
||||
return stats
|
||||
end
|
||||
|
||||
local function ensure_curmonth(stats)
|
||||
local lbl = curmonth_label()
|
||||
for _, s in ipairs(stats) do if s.month == lbl then return stats end end
|
||||
table.insert(stats, { month=lbl, rx=0, tx=0 })
|
||||
return stats
|
||||
end
|
||||
|
||||
local function get_interfaces()
|
||||
local ifaces = {}
|
||||
local p = io.popen("ls /etc/vnstat/ 2>/dev/null | grep -v '.conf$' | grep -v '.old$'")
|
||||
if p then
|
||||
for iface in p:lines() do
|
||||
iface = iface:match("^%s*(.-)%s*$")
|
||||
if iface ~= "" then table.insert(ifaces, iface) end
|
||||
end
|
||||
p:close()
|
||||
end
|
||||
table.sort(ifaces)
|
||||
return ifaces
|
||||
end
|
||||
|
||||
local function get_daily_stats(iface)
|
||||
local stats = {}
|
||||
local out = sys.exec("vnstat -i " .. iface .. " --days 2>/dev/null") or ""
|
||||
for date, rx_v, rx_u, tx_v, tx_u in
|
||||
out:gmatch("(%d%d/%d%d/%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
|
||||
table.insert(stats, {
|
||||
date = format_date(date),
|
||||
rx = to_bytes(rx_v, rx_u),
|
||||
tx = to_bytes(tx_v, tx_u)
|
||||
})
|
||||
end
|
||||
return ensure_today(stats)
|
||||
end
|
||||
|
||||
local function get_monthly_stats(iface)
|
||||
local stats = {}
|
||||
local out = sys.exec("vnstat -i " .. iface .. " --months 2>/dev/null") or ""
|
||||
for month, rx_v, rx_u, tx_v, tx_u in
|
||||
out:gmatch("(%a+%s+'%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
|
||||
table.insert(stats, {
|
||||
month = month,
|
||||
rx = to_bytes(rx_v, rx_u),
|
||||
tx = to_bytes(tx_v, tx_u)
|
||||
})
|
||||
end
|
||||
return ensure_curmonth(stats)
|
||||
end
|
||||
|
||||
local function fmt_bytes(v)
|
||||
if v >= 1099511627776 then return string.format("%.2f TB", v/1099511627776)
|
||||
elseif v >= 1073741824 then return string.format("%.2f GB", v/1073741824)
|
||||
elseif v >= 1048576 then return string.format("%.2f MB", v/1048576)
|
||||
elseif v >= 1024 then return string.format("%.2f KB", v/1024)
|
||||
end
|
||||
return tostring(v) .. " B"
|
||||
end
|
||||
|
||||
local ifaces = get_interfaces()
|
||||
local saved_iface = uci:get("netstats", "@config[0]", "prefer") or ""
|
||||
local saved_mode = uci:get("netstats", "@config[0]", "mode") or "daily"
|
||||
|
||||
local function has_db(name)
|
||||
for _, i in ipairs(ifaces) do if i == name then return true end end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Accepts a vnStat netdev name (e.g. "wwan0") or a logical interface name
|
||||
-- (e.g. "wan") resolved to its l3_device. Returns nil if it has no database.
|
||||
local function resolve_iface(name)
|
||||
if not name or name == "" or not name:match("^[%w%-%._]+$") then return nil end
|
||||
if has_db(name) then return name end
|
||||
local l3 = sys.exec("ubus call network.interface." .. name ..
|
||||
" status 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null") or ""
|
||||
l3 = l3:match("^%s*(.-)%s*$")
|
||||
if l3 ~= "" and has_db(l3) then return l3 end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Auto detect: the default-route device is the real uplink.
|
||||
local function default_route_iface()
|
||||
local out = sys.exec("ip route show default 2>/dev/null") or ""
|
||||
for dev in out:gmatch("dev%s+(%S+)") do
|
||||
if has_db(dev) then return dev end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local cur_iface = resolve_iface(http.formvalue("iface"))
|
||||
or resolve_iface(saved_iface)
|
||||
or default_route_iface()
|
||||
or ifaces[1] or ""
|
||||
local view_mode = http.formvalue("mode") or saved_mode
|
||||
local view_mode = http.formvalue("mode") or saved_mode
|
||||
if view_mode ~= "daily" and view_mode ~= "monthly" then view_mode = "daily" end
|
||||
|
||||
local stats, title = {}, ""
|
||||
if cur_iface ~= "" then
|
||||
if view_mode == "monthly" then
|
||||
stats = get_monthly_stats(cur_iface)
|
||||
title = translate("Monthly")
|
||||
else
|
||||
stats = get_daily_stats(cur_iface)
|
||||
title = translate("Daily")
|
||||
end
|
||||
end
|
||||
local title = view_mode == "monthly" and translate("Monthly") or translate("Daily")
|
||||
|
||||
local total_rx, total_tx = 0, 0
|
||||
for _, s in ipairs(stats) do
|
||||
total_rx = total_rx + (s.rx or 0)
|
||||
total_tx = total_tx + (s.tx or 0)
|
||||
-- Raw vnStat JSON, parsed in the browser by netstat/vnstat-parse.js.
|
||||
local raw = ""
|
||||
if cur_iface ~= "" then
|
||||
raw = nsu.vnstat_json_raw(view_mode == "monthly" and "m" or "d",
|
||||
view_mode == "monthly" and 12 or 30, cur_iface) or ""
|
||||
end
|
||||
local raw_js = (jsonc.stringify(raw) or '""'):gsub("</", "<\\/")
|
||||
%>
|
||||
|
||||
<link rel="stylesheet" href="/luci-static/resources/netstat/netstat-usage.css" />
|
||||
|
||||
<div class="netstat-dashboard">
|
||||
<p style="margin:0 0 12px;font-size:13px;color:#6b7280;">
|
||||
<p class="netstat-intro">
|
||||
<%:Usage is displayed using the Interface and View Mode selected in the Settings tab.%>
|
||||
</p>
|
||||
|
||||
<% if cur_iface ~= "" and #stats > 0 then %>
|
||||
<% if have_vnstat and cur_iface ~= "" then %>
|
||||
|
||||
<div id="netstat-usage-data" style="display:none">
|
||||
<div class="netstat-summary">
|
||||
<div class="netstat-summary-item">
|
||||
<span class="netstat-summary-dot rx"></span>
|
||||
<span class="netstat-summary-label"><%:Download%>:</span>
|
||||
<strong><%=fmt_bytes(total_rx)%></strong>
|
||||
<strong id="netstat-total-rx">-</strong>
|
||||
</div>
|
||||
<div class="netstat-summary-item">
|
||||
<span class="netstat-summary-dot tx"></span>
|
||||
<span class="netstat-summary-label"><%:Upload%>:</span>
|
||||
<strong><%=fmt_bytes(total_tx)%></strong>
|
||||
<strong id="netstat-total-tx">-</strong>
|
||||
</div>
|
||||
<div class="netstat-summary-item netstat-summary-total">
|
||||
<span class="netstat-summary-label"><%:Total%>:</span>
|
||||
<strong><%=fmt_bytes(total_rx + total_tx)%></strong>
|
||||
<strong id="netstat-total-all">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="netstat-chart-wrap">
|
||||
<div class="netstat-chart-header">
|
||||
<h3 class="netstat-chart-title"><%=title%> <%:Usage%></h3>
|
||||
<span class="netstat-chart-title"><%=title%> <%:Usage%></span>
|
||||
<span class="netstat-chart-badge"><%=cur_iface%></span>
|
||||
</div>
|
||||
<canvas id="trafficChart" class="netstat-chart-canvas"></canvas>
|
||||
<div class="netstat-chart-box"><canvas id="trafficChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="netstat-usage-empty" class="netstat-empty" style="display:none">
|
||||
<span class="netstat-empty-icon">📊</span>
|
||||
<%:No data available for%> <strong><%=cur_iface%></strong>
|
||||
<div class="netstat-hint">
|
||||
<%:vnStat may still be collecting its first data. It saves to its database every few minutes, try again shortly.%>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/luci-static/resources/netstat/chart.js"></script>
|
||||
<script src="/luci-static/resources/netstat/chartjs-plugin-datalabels"></script>
|
||||
<script src="/luci-static/resources/netstat/vnstat-parse.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var isDark = window._netstatDark || false;
|
||||
var textColor = isDark ? '#f3f4f6' : '#1f2937';
|
||||
var gridColor = isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)';
|
||||
// Chart text follows the theme's own text colour, so it is readable
|
||||
// on light and dark themes alike; grid lines are translucent grey.
|
||||
var dashboard = document.querySelector('.netstat-dashboard');
|
||||
function themeTextColor() {
|
||||
return getComputedStyle(dashboard).color || '#888';
|
||||
}
|
||||
var textColor = themeTextColor();
|
||||
var gridColor = 'rgba(128,128,128,0.2)';
|
||||
|
||||
var labels = [
|
||||
<% for i, stat in ipairs(stats) do
|
||||
local lbl = view_mode == "daily" and stat.date or stat.month
|
||||
%>"<%=lbl%>"<% if i < #stats then %>,<% end %>
|
||||
<% end %>
|
||||
];
|
||||
var rxData = [
|
||||
<% for i, stat in ipairs(stats) do
|
||||
%><%= math.floor(stat.rx) %><% if i < #stats then %>,<% end %>
|
||||
<% end %>
|
||||
];
|
||||
var txData = [
|
||||
<% for i, stat in ipairs(stats) do
|
||||
%><%= math.floor(stat.tx) %><% if i < #stats then %>,<% end %>
|
||||
<% end %>
|
||||
];
|
||||
var usage = window.netstatVnstat.series(<%=raw_js%>, '<%=cur_iface%>', '<%=view_mode%>');
|
||||
if (!usage.entries) {
|
||||
document.getElementById('netstat-usage-empty').style.display = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('netstat-usage-data').style.display = '';
|
||||
|
||||
var labels = usage.labels;
|
||||
var rxData = usage.rx;
|
||||
var txData = usage.tx;
|
||||
|
||||
// Height follows the number of rows; width follows the container
|
||||
// (Chart.js responsive mode), so the chart never overflows when the
|
||||
// theme's sidebar appears or the window is resized.
|
||||
var BAR_H = 48;
|
||||
var canvas = document.getElementById('trafficChart');
|
||||
canvas.height = Math.min(Math.max(labels.length * BAR_H, 280), 700);
|
||||
canvas.width = canvas.offsetWidth || canvas.parentElement.clientWidth;
|
||||
canvas.parentElement.style.height =
|
||||
Math.min(Math.max(labels.length * BAR_H, 280), 700) + 'px';
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
function rxGradient() {
|
||||
var g = ctx.createLinearGradient(0, 0, canvas.width || 600, 0);
|
||||
function rxGradient(c) {
|
||||
var area = c.chart.chartArea;
|
||||
if (!area) return '#4ade80';
|
||||
var g = c.chart.ctx.createLinearGradient(area.left, 0, area.right, 0);
|
||||
g.addColorStop(0, '#86efac');
|
||||
g.addColorStop(1, '#15803d');
|
||||
return g;
|
||||
@@ -222,9 +124,13 @@ end
|
||||
return v + ' B';
|
||||
}
|
||||
|
||||
document.getElementById('netstat-total-rx').textContent = fmtBytes(usage.totalRx);
|
||||
document.getElementById('netstat-total-tx').textContent = fmtBytes(usage.totalTx);
|
||||
document.getElementById('netstat-total-all').textContent = fmtBytes(usage.totalRx + usage.totalTx);
|
||||
|
||||
var maxVal = Math.max.apply(null, rxData.concat(txData).concat([1]));
|
||||
|
||||
new Chart(ctx, {
|
||||
var chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
@@ -232,7 +138,7 @@ end
|
||||
{
|
||||
label: '<%:Download (RX)%>',
|
||||
data: rxData,
|
||||
backgroundColor: rxData.map(function() { return rxGradient(); }),
|
||||
backgroundColor: rxGradient,
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
datalabels: {
|
||||
@@ -267,7 +173,7 @@ end
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 400 },
|
||||
plugins: {
|
||||
@@ -303,20 +209,51 @@ end
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
|
||||
// Recolour when the theme switches between light and dark without a
|
||||
// page reload (themes toggle a class or inline style on <html>/<body>).
|
||||
function recolour() {
|
||||
var c = themeTextColor();
|
||||
if (c === textColor) return;
|
||||
textColor = c;
|
||||
chart.options.plugins.legend.labels.color = c;
|
||||
chart.options.scales.x.ticks.color = c;
|
||||
chart.options.scales.y.ticks.color = c;
|
||||
chart.data.datasets[1].datalabels.color = c;
|
||||
chart.update('none');
|
||||
}
|
||||
// Themes may animate the colour change (e.g. "transition: all 0.2s"),
|
||||
// so check again once it has settled and whenever a colour
|
||||
// transition finishes.
|
||||
function recolourSoon() {
|
||||
setTimeout(recolour, 50);
|
||||
setTimeout(recolour, 500);
|
||||
}
|
||||
document.addEventListener('transitionend', function (ev) {
|
||||
if (ev.propertyName === 'color') recolour();
|
||||
});
|
||||
if (window.MutationObserver) {
|
||||
var mo = new MutationObserver(recolourSoon);
|
||||
[document.documentElement, document.body].forEach(function (el) {
|
||||
mo.observe(el, { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-darkmode'] });
|
||||
});
|
||||
var darkStyle = document.getElementById('dark-theme-style');
|
||||
if (darkStyle) mo.observe(darkStyle, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<% elseif #ifaces == 0 then %>
|
||||
<% elseif not have_vnstat then %>
|
||||
<div class="netstat-empty">
|
||||
<span class="netstat-empty-icon">📡</span>
|
||||
<%:No vnStat interfaces were found. Make sure vnStat has been initialized.%>
|
||||
<%:vnStat is not installed. Install the vnstat2 package to record traffic history.%>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="netstat-empty">
|
||||
<span class="netstat-empty-icon">📊</span>
|
||||
<%:No data available for%> <strong><%=cur_iface%></strong>
|
||||
<span class="netstat-empty-icon">📡</span>
|
||||
<%:vnStat is not tracking any interface yet.%>
|
||||
<div class="netstat-hint">
|
||||
<%:vnStat may still be collecting its first data. Try again in a few minutes.%>
|
||||
<%:Interfaces are added automatically when they come up. You can also run "/usr/libexec/netstat-vnstat-track" on the router.%>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
<%+header%>
|
||||
|
||||
<%
|
||||
local sys = require "luci.sys"
|
||||
local http = require "luci.http"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
|
||||
local function to_bytes(value, unit)
|
||||
local multipliers = {
|
||||
["KiB"] = 1024,
|
||||
["MiB"] = 1024^2,
|
||||
["GiB"] = 1024^3,
|
||||
["TiB"] = 1024^4
|
||||
}
|
||||
return tonumber(value) * (multipliers[unit] or 1)
|
||||
end
|
||||
|
||||
local function format_date(date_str)
|
||||
local month_names = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
|
||||
local m, d, y = date_str:match("(%d%d)/(%d%d)/(%d%d)")
|
||||
if m and d and y then
|
||||
return string.format("%02d %s %02d", tonumber(d), month_names[tonumber(m)], tonumber(y))
|
||||
end
|
||||
return date_str
|
||||
end
|
||||
|
||||
local function get_interfaces()
|
||||
local ifaces = {}
|
||||
local p = io.popen("ls /etc/vnstat/ 2>/dev/null | grep -v '.conf$' | grep -v '.old$'")
|
||||
if p then
|
||||
for iface in p:lines() do
|
||||
iface = iface:match("^%s*(.-)%s*$")
|
||||
if iface ~= "" then table.insert(ifaces, iface) end
|
||||
end
|
||||
p:close()
|
||||
end
|
||||
table.sort(ifaces)
|
||||
return ifaces
|
||||
end
|
||||
|
||||
local function get_daily_stats(iface)
|
||||
local stats = {}
|
||||
local output = sys.exec("vnstat -i " .. iface .. " --days 2>/dev/null") or ""
|
||||
for date, rx_val, rx_unit, tx_val, tx_unit in
|
||||
output:gmatch("(%d%d/%d%d/%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
|
||||
table.insert(stats, {
|
||||
date = format_date(date),
|
||||
rx = to_bytes(rx_val, rx_unit),
|
||||
tx = to_bytes(tx_val, tx_unit)
|
||||
})
|
||||
end
|
||||
return stats
|
||||
end
|
||||
|
||||
local function get_monthly_stats(iface)
|
||||
local stats = {}
|
||||
local output = sys.exec("vnstat -i " .. iface .. " --months 2>/dev/null") or ""
|
||||
for month, rx_val, rx_unit, tx_val, tx_unit in
|
||||
output:gmatch("(%a+%s+'%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
|
||||
table.insert(stats, {
|
||||
month = month,
|
||||
rx = to_bytes(rx_val, rx_unit),
|
||||
tx = to_bytes(tx_val, tx_unit)
|
||||
})
|
||||
end
|
||||
return stats
|
||||
end
|
||||
|
||||
local ifaces = get_interfaces()
|
||||
local current_iface = http.formvalue("iface") or "wwan0_1"
|
||||
local view_mode = http.formvalue("mode") or "daily"
|
||||
local stats, title = {}, ""
|
||||
|
||||
if current_iface ~= "" then
|
||||
if view_mode == "daily" then
|
||||
stats = get_daily_stats(current_iface)
|
||||
title = "<%:Daily%>"
|
||||
else
|
||||
stats = get_monthly_stats(current_iface)
|
||||
title = "<%:Monthly%>"
|
||||
end
|
||||
end
|
||||
%>
|
||||
|
||||
<div class="cbi-map">
|
||||
<h2 name="content"><%:Network Usage%></h2>
|
||||
|
||||
<div class="cbi-section">
|
||||
<!-- Interface & Mode Select -->
|
||||
<form method="get" class="inline">
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title"><%:Interface%></label>
|
||||
<div class="cbi-value-field">
|
||||
<select name="iface" onchange="this.form.submit()" class="cbi-input-select">
|
||||
<% if #ifaces == 0 then %>
|
||||
<option value=""><%:No interfaces found%></option>
|
||||
<% else %>
|
||||
<% for _, iface in ipairs(ifaces) do %>
|
||||
<option value="<%=iface%>" <%=current_iface == iface and 'selected="selected"' or ''%>><%=iface%></option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title"><%:View Mode%></label>
|
||||
<div class="cbi-value-field">
|
||||
<select name="mode" onchange="this.form.submit()" class="cbi-input-select">
|
||||
<option value="daily" <%=view_mode == "daily" and 'selected="selected"' or ''%>><%:Daily%></option>
|
||||
<option value="monthly" <%=view_mode == "monthly" and 'selected="selected"' or ''%>><%:Monthly%></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<% if current_iface ~= "" and #stats > 0 then %>
|
||||
<div class="cbi-section">
|
||||
<h3><%=title%> <small>(<%=current_iface%>)</small></h3>
|
||||
<div style="width: 100%; height: auto; min-height: 350px;">
|
||||
<canvas id="trafficChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/luci-static/resources/netstat/chart.js"></script>
|
||||
<script src="/luci-static/resources/netstat/chartjs-plugin-datalabels"></script>
|
||||
<script>
|
||||
// Detect background brightness
|
||||
function getTextColor() {
|
||||
const bodyColor = window.getComputedStyle(document.body).backgroundColor;
|
||||
if (!bodyColor) return '#000';
|
||||
const rgb = bodyColor.match(/\d+/g).map(Number);
|
||||
const brightness = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
|
||||
return brightness < 128 ? '#fff' : '#000';
|
||||
}
|
||||
const textColor = getTextColor();
|
||||
|
||||
// Dynamic height per row
|
||||
const barHeight = 60; // bigger for mobile
|
||||
const chartCanvas = document.getElementById('trafficChart');
|
||||
chartCanvas.height = Math.max(350, <%=#stats%> * barHeight);
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
|
||||
// Download gradient (light green → dark green)
|
||||
function getGradient(ctx, value, maxValue) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, chartCanvas.width, 0);
|
||||
gradient.addColorStop(0, '#90ee90'); // light green
|
||||
gradient.addColorStop(1, '#006400'); // dark green
|
||||
return gradient;
|
||||
}
|
||||
|
||||
// Format bytes -> readable
|
||||
function formatBytes(value) {
|
||||
if (value > 1099511627776) return (value/1099511627776).toFixed(2) + ' TB';
|
||||
if (value > 1073741824) return (value/1073741824).toFixed(2) + ' GB';
|
||||
if (value > 1048576) return (value/1048576).toFixed(2) + ' MB';
|
||||
if (value > 1024) return (value/1024).toFixed(2) + ' KB';
|
||||
return value + ' B';
|
||||
}
|
||||
|
||||
// Data arrays
|
||||
const rxData = [
|
||||
<% for _, stat in ipairs(stats) do %>
|
||||
<%= math.floor(stat.rx) %>,
|
||||
<% end %>
|
||||
];
|
||||
const txData = [
|
||||
<% for _, stat in ipairs(stats) do %>
|
||||
<%= math.floor(stat.tx) %>,
|
||||
<% end %>
|
||||
];
|
||||
const maxValue = Math.max(...rxData, ...txData, 1);
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [
|
||||
<% for _, stat in ipairs(stats) do %>
|
||||
"<%= view_mode == "daily" and stat.date or stat.month %>",
|
||||
<% end %>
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: '<%:Download (RX)%>',
|
||||
backgroundColor: rxData.map(v => getGradient(ctx, v, maxValue)),
|
||||
data: rxData,
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
anchor: 'center',
|
||||
align: 'center',
|
||||
formatter: function(value) { return formatBytes(value); }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '<%:Upload (TX)%>',
|
||||
backgroundColor: 'orange',
|
||||
data: txData,
|
||||
datalabels: {
|
||||
color: textColor,
|
||||
anchor: 'end',
|
||||
align: 'right',
|
||||
formatter: function(value) { return formatBytes(value); }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: { color: textColor }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return formatBytes(context.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: textColor,
|
||||
callback: function(value) {
|
||||
return formatBytes(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: { color: textColor, autoSkip: false }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
</script>
|
||||
<% else %>
|
||||
<div class="alert-message warning">
|
||||
<%:No data available for %> <%=current_iface%>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%+footer%>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# Make vnStat track this router's network devices without any hard-coded
|
||||
# names: every netifd interface that is up (WAN, LAN, modem, VPN, ...) gets
|
||||
# its L3 device added to the "interface" list in /etc/config/vnstat, which
|
||||
# the vnstat2 init script feeds to "vnstat --add".
|
||||
#
|
||||
# netstat-vnstat-track add the devices of all interfaces that are up
|
||||
# netstat-vnstat-track <dev>... add the given devices
|
||||
#
|
||||
# Set "option vnstat_autoadd '0'" in /etc/config/netstats to turn this off.
|
||||
|
||||
[ -x /usr/bin/vnstat ] || exit 0
|
||||
[ "$(uci -q get netstats.@config[0].vnstat_autoadd)" = "0" ] && exit 0
|
||||
|
||||
valid_name() {
|
||||
case "$1" in
|
||||
""|lo|*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
discover() {
|
||||
ubus call network.interface dump 2>/dev/null | \
|
||||
jsonfilter -e '@.interface[@.up=true].l3_device' 2>/dev/null
|
||||
}
|
||||
|
||||
[ $# -gt 0 ] && devices="$*" || devices="$(discover)"
|
||||
[ -n "$devices" ] || exit 0
|
||||
|
||||
lock /var/lock/netstat-vnstat-track
|
||||
|
||||
# vnstat2 ships "config vnstat" in /etc/config/vnstat; recreate it if missing.
|
||||
touch /etc/config/vnstat
|
||||
uci -q get vnstat.@vnstat[0] >/dev/null || uci -q add vnstat vnstat >/dev/null
|
||||
|
||||
tracked=" $(uci -q get vnstat.@vnstat[0].interface) "
|
||||
changed=0
|
||||
for dev in $devices; do
|
||||
valid_name "$dev" || continue
|
||||
case "$tracked" in *" $dev "*) continue ;; esac
|
||||
uci -q add_list vnstat.@vnstat[0].interface="$dev"
|
||||
tracked="$tracked$dev "
|
||||
changed=1
|
||||
done
|
||||
|
||||
if [ "$changed" = 1 ]; then
|
||||
uci -q commit vnstat
|
||||
# reload runs "vnstat --add" for the list and sends SIGHUP so vnstatd
|
||||
# picks the new entries up straight away.
|
||||
if /etc/init.d/vnstat running 2>/dev/null; then
|
||||
/etc/init.d/vnstat reload >/dev/null 2>&1
|
||||
elif /etc/init.d/vnstat enabled 2>/dev/null; then
|
||||
/etc/init.d/vnstat start >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
|
||||
lock -u /var/lock/netstat-vnstat-track
|
||||
exit 0
|
||||
@@ -1,20 +1,24 @@
|
||||
/* netstat-usage.css */
|
||||
/* netstat-usage.css
|
||||
*
|
||||
* Layout only. Backgrounds, borders and text colours all come from the
|
||||
* active LuCI theme (the pages sit inside its standard cbi-section and use
|
||||
* its cbi-tabmenu tabs), so they follow the theme switcher, light or dark.
|
||||
*/
|
||||
.netstat-dashboard {
|
||||
border: 1px solid #dfe3eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
--ns-muted: 0.7;
|
||||
}
|
||||
|
||||
.netstat-intro {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
opacity: var(--ns-muted);
|
||||
}
|
||||
|
||||
.netstat-summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #dfe3eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -23,7 +27,6 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.netstat-summary-dot {
|
||||
@@ -37,7 +40,7 @@
|
||||
.netstat-summary-dot.tx { background: #d97706; }
|
||||
|
||||
.netstat-summary-label {
|
||||
color: #6b7280;
|
||||
opacity: var(--ns-muted);
|
||||
font-weight: 400;
|
||||
margin-right: 2px;
|
||||
}
|
||||
@@ -46,48 +49,36 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.netstat-chart-wrap {
|
||||
border: 1px solid #dfe4ea;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.netstat-chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.netstat-chart-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.netstat-chart-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 12px;
|
||||
opacity: var(--ns-muted);
|
||||
}
|
||||
|
||||
.netstat-chart-canvas {
|
||||
.netstat-chart-badge::before { content: "("; }
|
||||
.netstat-chart-badge::after { content: ")"; }
|
||||
|
||||
.netstat-chart-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.netstat-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -100,11 +91,5 @@
|
||||
.netstat-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.netstat-dashboard { padding: 12px; }
|
||||
.netstat-summary { gap: 8px; }
|
||||
.netstat-chart-wrap { padding: 10px; }
|
||||
opacity: var(--ns-muted);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Turns `vnstat --json d|m` output into chart series for the Network Usage
|
||||
* tab. Handles vnStat 2 (jsonversion "2": bytes, keys "day"/"month") and the
|
||||
* older jsonversion "1" layout (KiB, keys "days"/"months").
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
|
||||
'Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
||||
|
||||
function dayLabel(y, m, d) {
|
||||
return pad2(d) + ' ' + MONTHS[m - 1] + ' ' + pad2(y % 100);
|
||||
}
|
||||
|
||||
function monthLabel(y, m) {
|
||||
return MONTHS[m - 1] + " '" + pad2(y % 100);
|
||||
}
|
||||
|
||||
function findInterface(data, name) {
|
||||
var list = (data && Array.isArray(data.interfaces)) ? data.interfaces : [];
|
||||
for (var i = 0; i < list.length; i++)
|
||||
if (list[i] && list[i].name === name) return list[i];
|
||||
return list.length === 1 ? list[0] : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* data: parsed vnStat JSON (or the raw string)
|
||||
* iface: interface name to pick from data.interfaces
|
||||
* mode: 'daily' | 'monthly'
|
||||
* now: Date used to add an empty entry for today / this month
|
||||
*
|
||||
* Returns { labels, rx, tx, totalRx, totalTx, entries } where entries is
|
||||
* the number of rows vnStat actually had (0 while it is still collecting).
|
||||
*/
|
||||
function series(data, iface, mode, now) {
|
||||
var out = { labels: [], rx: [], tx: [], totalRx: 0, totalTx: 0, entries: 0 };
|
||||
|
||||
if (typeof data === 'string') {
|
||||
try { data = JSON.parse(data); } catch (e) { return out; }
|
||||
}
|
||||
|
||||
var ifc = findInterface(data, iface);
|
||||
if (!ifc || !ifc.traffic) return out;
|
||||
|
||||
var legacy = String(data.jsonversion) === '1';
|
||||
var scale = legacy ? 1024 : 1;
|
||||
var monthly = mode === 'monthly';
|
||||
var key = monthly ? (legacy ? 'months' : 'month') : (legacy ? 'days' : 'day');
|
||||
var rows = Array.isArray(ifc.traffic[key]) ? ifc.traffic[key].slice() : [];
|
||||
|
||||
rows = rows.filter(function (r) {
|
||||
return r && r.date && r.date.year && r.date.month && (monthly || r.date.day);
|
||||
});
|
||||
|
||||
function sortKey(r) {
|
||||
return r.date.year * 10000 + r.date.month * 100 + (monthly ? 0 : r.date.day);
|
||||
}
|
||||
rows.sort(function (a, b) { return sortKey(a) - sortKey(b); });
|
||||
|
||||
out.entries = rows.length;
|
||||
if (!rows.length) return out;
|
||||
|
||||
rows.forEach(function (r) {
|
||||
var rx = (Number(r.rx) || 0) * scale;
|
||||
var tx = (Number(r.tx) || 0) * scale;
|
||||
out.labels.push(monthly ? monthLabel(r.date.year, r.date.month)
|
||||
: dayLabel(r.date.year, r.date.month, r.date.day));
|
||||
out.rx.push(rx);
|
||||
out.tx.push(tx);
|
||||
out.totalRx += rx;
|
||||
out.totalTx += tx;
|
||||
});
|
||||
|
||||
// Always show the current period, even before any traffic was saved.
|
||||
now = now || new Date();
|
||||
var cur = monthly ? monthLabel(now.getFullYear(), now.getMonth() + 1)
|
||||
: dayLabel(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
if (out.labels.indexOf(cur) < 0) {
|
||||
out.labels.push(cur);
|
||||
out.rx.push(0);
|
||||
out.tx.push(0);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
var api = { series: series, findInterface: findInterface };
|
||||
|
||||
if (typeof module === 'object' && module.exports)
|
||||
module.exports = api;
|
||||
else
|
||||
root.netstatVnstat = api;
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -77,17 +77,50 @@ function parseNetdev(raw) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// The backend resolves the tracked device (Settings choice or default-route
|
||||
// device). Without one, fall back to the busiest non-loopback interface
|
||||
// rather than guessing names.
|
||||
function getBestWAN(stats, preferred) {
|
||||
for (const i of preferred) if (stats[i]) return i;
|
||||
|
||||
const dynamic = Object.keys(stats).find(i =>
|
||||
/^(wwan|usb|ppp|lte|qmi|modem)/.test(i) && i.includes('_'));
|
||||
if (dynamic) return dynamic;
|
||||
let best = '', bestBytes = -1;
|
||||
for (const k of Object.keys(stats)) {
|
||||
if (k === 'lo') continue;
|
||||
const bytes = (parseInt(stats[k].rx) || 0) + (parseInt(stats[k].tx) || 0);
|
||||
if (bytes > bestBytes) { best = k; bestBytes = bytes; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
for (const i of ['pppoe-wan','lte0','usb0','wan','eth1','tun0','wg0'])
|
||||
if (stats[i]) return i;
|
||||
// Totals for the downloaded/uploaded boxes: this month from vnStat when the
|
||||
// vnStat backend is selected, otherwise the since-boot kernel counters.
|
||||
function getTotals(data, curr) {
|
||||
const v = data.vnstat_total;
|
||||
if (v && typeof v === 'object')
|
||||
return { rx: parseInt(v.rx) || 0, tx: parseInt(v.tx) || 0 };
|
||||
return curr;
|
||||
}
|
||||
|
||||
return Object.keys(stats).find(k => k !== 'lo') || '';
|
||||
const STATS_URL = L.url('admin/tools/get_netdev_stats');
|
||||
|
||||
// Normalise one backend response into the shape render()/updateContainer() use.
|
||||
function toData(r, ip) {
|
||||
return {
|
||||
stats: (r && r.stats) || {},
|
||||
ip: ip,
|
||||
status: (r && r.status) || 'Disconnected',
|
||||
uptime: (r && r.uptime) || 0,
|
||||
cpu_pct: (r && r.cpu_pct) || 0,
|
||||
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
|
||||
mem_pct: (r && r.mem_pct) || 0,
|
||||
mem_used: (r && r.mem_used) || 0,
|
||||
mem_total: (r && r.mem_total) || 0,
|
||||
disk_pct: (r && r.disk_pct) || 0,
|
||||
disk_used: (r && r.disk_used) || 0,
|
||||
disk_total: (r && r.disk_total) || 0,
|
||||
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : [],
|
||||
vnstat_total: (r && r.vnstat_total) || null
|
||||
};
|
||||
}
|
||||
|
||||
function formatRate(bps) {
|
||||
@@ -381,8 +414,9 @@ function updateContainer(container, data, dt) {
|
||||
|
||||
const rxRate = formatRate(rxSpeed * 8);
|
||||
const txRate = formatRate(txSpeed * 8);
|
||||
const totalRx = formatBytes(curr.rx).split(' ');
|
||||
const totalTx = formatBytes(curr.tx).split(' ');
|
||||
const sums = getTotals(data, curr);
|
||||
const totalRx = formatBytes(sums.rx).split(' ');
|
||||
const totalTx = formatBytes(sums.tx).split(' ');
|
||||
const peakRxFmt = formatRate(peakRx * 8);
|
||||
const peakTxFmt = formatRate(peakTx * 8);
|
||||
|
||||
@@ -457,18 +491,34 @@ function updateContainer(container, data, dt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Cached public IP (refreshed every 60 s, not on every 2 s poll) ──────────
|
||||
let _cachedIp = 'N/A';
|
||||
// ─── Cached public IP (refreshed every 5 min, not on every 2 s poll) ─────────
|
||||
// The public IP (as seen through any proxy such as Passwall) is shown once
|
||||
// known; the tracked device's local address is only a fallback until then,
|
||||
// so the 2 s fast polls must never overwrite it.
|
||||
let _publicIp = null;
|
||||
let _localIp = 'N/A';
|
||||
let _lastIpFetch = 0;
|
||||
const IP_REFRESH_MS = 300000;
|
||||
const IP_RETRY_MS = 30000;
|
||||
|
||||
function shownIp() {
|
||||
return _publicIp || _localIp;
|
||||
}
|
||||
|
||||
function noteLocalIp(r) {
|
||||
if (r && r.ip && r.ip !== 'N/A') _localIp = r.ip;
|
||||
}
|
||||
|
||||
function maybeRefreshIp() {
|
||||
const now = Date.now();
|
||||
if (now - _lastIpFetch < IP_REFRESH_MS) return;
|
||||
if (now - _lastIpFetch < (_publicIp ? IP_REFRESH_MS : IP_RETRY_MS)) return;
|
||||
_lastIpFetch = now;
|
||||
// Full fetch (no ?fast=1) to get public IP; update cache on success
|
||||
fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats', 8000)
|
||||
.then(r => { if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip; })
|
||||
fetchWithTimeout(STATS_URL, 8000)
|
||||
.then(r => {
|
||||
if (r && r.ip_source === 'public' && r.ip && r.ip !== 'N/A')
|
||||
_publicIp = r.ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -478,29 +528,13 @@ return baseclass.extend({
|
||||
|
||||
load() {
|
||||
// Use ?fast=1 to skip slow public-IP APIs — response is near-instant
|
||||
return fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats?fast=1', 5000)
|
||||
return fetchWithTimeout(STATS_URL + '?fast=1', 5000)
|
||||
.then(r => {
|
||||
// Seed the cached IP from the local WAN address on first load
|
||||
if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip;
|
||||
return {
|
||||
stats: (r && r.stats) || {},
|
||||
ip: _cachedIp,
|
||||
status: (r && r.status) || 'Disconnected',
|
||||
uptime: (r && r.uptime) || 0,
|
||||
cpu_pct: (r && r.cpu_pct) || 0,
|
||||
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
|
||||
mem_pct: (r && r.mem_pct) || 0,
|
||||
mem_used: (r && r.mem_used) || 0,
|
||||
mem_total: (r && r.mem_total) || 0,
|
||||
disk_pct: (r && r.disk_pct) || 0,
|
||||
disk_used: (r && r.disk_used) || 0,
|
||||
disk_total: (r && r.disk_total) || 0,
|
||||
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : []
|
||||
};
|
||||
// Show the local WAN address until the public IP arrives
|
||||
noteLocalIp(r);
|
||||
return toData(r, shownIp());
|
||||
})
|
||||
.catch(() => ({ stats: {}, ip: 'N/A', status: 'Disconnected',
|
||||
uptime: 0, cpu_pct: 0, cpu_temp: null, mem_pct: 0, mem_used: 0, mem_total: 0,
|
||||
disk_pct: 0, disk_used: 0, disk_total: 0, preferred: [] }));
|
||||
.catch(() => toData(null, 'N/A'));
|
||||
},
|
||||
|
||||
render(data) {
|
||||
@@ -538,8 +572,9 @@ return baseclass.extend({
|
||||
|
||||
const rxRate = formatRate(rxSpeed * 8);
|
||||
const txRate = formatRate(txSpeed * 8);
|
||||
const totalRx = formatBytes(curr.rx).split(' ');
|
||||
const totalTx = formatBytes(curr.tx).split(' ');
|
||||
const sums = getTotals(data, curr);
|
||||
const totalRx = formatBytes(sums.rx).split(' ');
|
||||
const totalTx = formatBytes(sums.tx).split(' ');
|
||||
|
||||
// Build download box manually to inject trend badge
|
||||
const dlBox = createRateBox(_('download'), rxRate.number, rxRate.unit,
|
||||
@@ -573,31 +608,17 @@ return baseclass.extend({
|
||||
L.Poll.add(() => {
|
||||
// Periodically refresh the public IP in the background (non-blocking)
|
||||
maybeRefreshIp();
|
||||
return fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats?fast=1', 5000)
|
||||
return fetchWithTimeout(STATS_URL + '?fast=1', 5000)
|
||||
.then(r => {
|
||||
const now2 = Date.now();
|
||||
const dt2 = Math.max(0.1, (now2 - last_time) / 1000);
|
||||
last_time = now2;
|
||||
|
||||
// Update cached IP from local WAN if we got a valid one
|
||||
if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip;
|
||||
// Keep the local address current as a fallback only
|
||||
noteLocalIp(r);
|
||||
|
||||
if (_container && _container.isConnected) {
|
||||
updateContainer(_container, {
|
||||
stats: (r && r.stats) || {},
|
||||
ip: _cachedIp,
|
||||
status: (r && r.status) || 'Disconnected',
|
||||
uptime: (r && r.uptime) || 0,
|
||||
cpu_pct: (r && r.cpu_pct) || 0,
|
||||
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
|
||||
mem_pct: (r && r.mem_pct) || 0,
|
||||
mem_used: (r && r.mem_used) || 0,
|
||||
mem_total: (r && r.mem_total) || 0,
|
||||
disk_pct: (r && r.disk_pct) || 0,
|
||||
disk_used: (r && r.disk_used) || 0,
|
||||
disk_total: (r && r.disk_total) || 0,
|
||||
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : []
|
||||
}, dt2);
|
||||
updateContainer(_container, toData(r, shownIp()), dt2);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
Reference in new issue
Block a user