mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 08:55:13 +08:00
🤞 Sync 2026-09-27 22:16:29
This commit is contained in:
1 parent
8ede4a8320
commit
618f51e9dc
459 files changed
+44817
-2191
No files matched your search
@@ -0,0 +1,249 @@
|
||||
module("luci.controller.netstat", package.seeall)
|
||||
local nixio = require("nixio")
|
||||
local uci = require("luci.model.uci").cursor()
|
||||
|
||||
function index()
|
||||
entry({"admin", "tools"}, firstchild(), _("Tools"), 50).dependent = false
|
||||
entry({"admin", "tools", "netstat"}, cbi("netstat/config"), _("Netstat"), 20).leaf = true
|
||||
entry({"admin", "tools", "netstat_config"}, alias("admin", "tools", "netstat")).dependent = true
|
||||
entry({"admin", "tools", "get_netdev_stats"}, call("getNetdevStats"), nil).sysauth = false
|
||||
end
|
||||
|
||||
-- Reads /proc/net/dev and returns per-interface rx/tx byte counters.
|
||||
function getNetdevStats()
|
||||
local f = io.open("/proc/net/dev", "r")
|
||||
if not f then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write('{"stats":{},"ip":"N/A","status":"Disconnected"}')
|
||||
return
|
||||
end
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
|
||||
local stats = {}
|
||||
for line in content:gmatch("[^\n]+") do
|
||||
local iface, values = line:match("^%s*([^:]+):%s+(.*)$")
|
||||
if iface and values then
|
||||
local nums = {}
|
||||
for num in values:gmatch("%d+") do
|
||||
table.insert(nums, tonumber(num))
|
||||
end
|
||||
if #nums >= 9 then
|
||||
stats[iface] = { rx = nums[1], tx = nums[9] }
|
||||
end
|
||||
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 function read_cmd(cmd)
|
||||
local p = io.popen(cmd)
|
||||
if not p then return nil end
|
||||
local line = p:read("*l")
|
||||
p:close()
|
||||
if not line then return nil end
|
||||
line = line:gsub("^%s+",""):gsub("%s+$","")
|
||||
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
|
||||
|
||||
local function is_valid_ip(v)
|
||||
if not v then return false end
|
||||
local a,b,c,d = v:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||||
if a and b and c and d then
|
||||
a,b,c,d = tonumber(a),tonumber(b),tonumber(c),tonumber(d)
|
||||
if a<=255 and b<=255 and c<=255 and d<=255 then return true end
|
||||
end
|
||||
if v:find(":",1,true) and v:match("^[%x:]+$") then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
local ip = "N/A"
|
||||
|
||||
-- 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
|
||||
-- poll ticks and omits it only when explicitly requesting a public IP refresh.
|
||||
local want_public = luci.http.formvalue("fast") ~= "1"
|
||||
|
||||
-- 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",
|
||||
}
|
||||
for _, cmd in ipairs(public_cmds) do
|
||||
local v = read_cmd(cmd)
|
||||
if is_valid_ip(v) then ip = v; 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
|
||||
end
|
||||
|
||||
-- ── System uptime ─────────────────────────────────────────────────────────
|
||||
local uptime_sec = 0
|
||||
local uf = io.open("/proc/uptime", "r")
|
||||
if uf then
|
||||
local line = uf:read("*l")
|
||||
uf:close()
|
||||
if line then
|
||||
uptime_sec = math.floor(tonumber(line:match("^([%d%.]+)")) or 0)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── CPU usage (two reads 200 ms apart for accurate delta) ────────────────
|
||||
local cpu_pct = 0
|
||||
local function read_cpu_stat()
|
||||
local f = io.open("/proc/stat", "r")
|
||||
if not f then return nil end
|
||||
local line = f:read("*l"); f:close()
|
||||
local vals = {}
|
||||
for n in line:gmatch("%d+") do vals[#vals+1] = tonumber(n) end
|
||||
if #vals < 4 then return nil end
|
||||
local idle, total = vals[4], 0
|
||||
for _, v in ipairs(vals) do total = total + v end
|
||||
return { idle = idle, total = total }
|
||||
end
|
||||
local s1 = read_cpu_stat()
|
||||
if s1 then
|
||||
nixio.nanosleep(0, 200000000)
|
||||
local s2 = read_cpu_stat()
|
||||
if s2 then
|
||||
local d_total = s2.total - s1.total
|
||||
local d_idle = s2.idle - s1.idle
|
||||
if d_total > 0 then
|
||||
cpu_pct = math.floor((d_total - d_idle) / d_total * 100 + 0.5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Memory usage ──────────────────────────────────────────────────────────
|
||||
local mem_total, mem_available, mem_free = 0, 0, 0
|
||||
local mf = io.open("/proc/meminfo", "r")
|
||||
if mf then
|
||||
for line in mf:lines() do
|
||||
local k, v = line:match("^(%S+):%s+(%d+)")
|
||||
if k == "MemTotal" then mem_total = tonumber(v) or 0 end
|
||||
if k == "MemAvailable" then mem_available = tonumber(v) or 0 end
|
||||
if k == "MemFree" then mem_free = tonumber(v) or 0 end
|
||||
end
|
||||
mf:close()
|
||||
end
|
||||
-- MemAvailable is present on Linux 3.14+; fall back to MemFree on older kernels
|
||||
if mem_available == 0 and mem_free > 0 then mem_available = mem_free end
|
||||
local mem_pct = 0
|
||||
if mem_total > 0 then
|
||||
mem_pct = math.floor((mem_total - mem_available) / mem_total * 100 + 0.5)
|
||||
end
|
||||
-- used MB and total MB for display
|
||||
local mem_used_mb = math.floor((mem_total - mem_available) / 1024 + 0.5)
|
||||
local mem_total_mb = math.floor(mem_total / 1024 + 0.5)
|
||||
|
||||
-- ── Disk space (root filesystem) ──────────────────────────────────────────
|
||||
local disk_pct, disk_used_mb, disk_total_mb = 0, 0, 0
|
||||
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
|
||||
dused = tonumber(dused) or 0
|
||||
if dtotal > 0 then
|
||||
disk_pct = math.floor(dused / dtotal * 100 + 0.5)
|
||||
disk_used_mb = math.floor(dused / 1024 + 0.5)
|
||||
disk_total_mb = math.floor(dtotal / 1024 + 0.5)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── CPU temperature ───────────────────────────────────────────────────────
|
||||
local cpu_temp = nil
|
||||
-- Scan all available thermal zones and pick the highest valid reading
|
||||
-- (avoids hardcoded zone numbers which vary per device)
|
||||
for zone = 0, 9 do
|
||||
local path = "/sys/class/thermal/thermal_zone" .. zone .. "/temp"
|
||||
local tf = io.open(path, "r")
|
||||
if tf then
|
||||
local val = tf:read("*l")
|
||||
tf:close()
|
||||
local t = tonumber(val)
|
||||
if t and t > 0 then
|
||||
-- values > 1000 are in millidegrees
|
||||
if t > 1000 then t = t / 1000 end
|
||||
t = math.floor(t + 0.5)
|
||||
-- Keep the highest temperature reading across all zones
|
||||
if cpu_temp == nil or t > cpu_temp then
|
||||
cpu_temp = t
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ stats = stats, ip = ip, status = status,
|
||||
prefer_iface = prefer_iface,
|
||||
uptime = uptime_sec,
|
||||
cpu_pct = cpu_pct,
|
||||
cpu_temp = cpu_temp,
|
||||
mem_pct = mem_pct,
|
||||
mem_used = mem_used_mb,
|
||||
mem_total = mem_total_mb,
|
||||
disk_pct = disk_pct,
|
||||
disk_used = disk_used_mb,
|
||||
disk_total = disk_total_mb })
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
local net = require "luci.model.network"
|
||||
local sys = require "luci.sys"
|
||||
local http = require "luci.http"
|
||||
|
||||
local m = Map("netstats", translate("Netstat"),
|
||||
translate("Configure traffic monitoring and view usage history from a single page.")
|
||||
)
|
||||
|
||||
local active_tab = http.formvalue("tab") or "usage"
|
||||
|
||||
local tabs = m:section(SimpleSection)
|
||||
tabs.template = "netstat/tabs"
|
||||
|
||||
if active_tab == "settings" then
|
||||
local s = m:section(TypedSection, "config", translate("Monitoring Settings"))
|
||||
s.anonymous = true
|
||||
s.addremove = false
|
||||
|
||||
-- Traffic backend
|
||||
local backend = s:option(ListValue, "backend", translate("Traffic Backend"))
|
||||
backend.default = "normal"
|
||||
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."
|
||||
)
|
||||
|
||||
-- View mode
|
||||
local mode = s:option(ListValue, "mode", translate("View Mode"))
|
||||
mode.default = "daily"
|
||||
mode:value("daily", translate("Daily Usage"))
|
||||
mode:value("monthly", translate("Monthly Usage"))
|
||||
mode.description = translate("Default view shown on the Network Usage tab.")
|
||||
|
||||
-- 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."
|
||||
)
|
||||
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)
|
||||
end
|
||||
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 ""
|
||||
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
|
||||
)
|
||||
end
|
||||
|
||||
-- Reset button with inline JS confirmation
|
||||
local reset = s:option(Button, "_reset", translate("Reset Historical Data"))
|
||||
reset.inputtitle = translate("Reset Database")
|
||||
reset.inputstyle = "reset"
|
||||
reset.description = translate(
|
||||
"<strong style='color:#b91c1c;'>Warning:</strong> " ..
|
||||
"This permanently deletes all stored traffic history for every interface."
|
||||
)
|
||||
|
||||
-- Inject confirmation dialog via rawhtml trick
|
||||
local confirm_js = s:option(DummyValue, "_confirm_js", "")
|
||||
confirm_js.rawhtml = true
|
||||
confirm_js.cfgvalue = function()
|
||||
return [[
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.querySelector('input[value="]] .. translate("Reset Database") .. [["]');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
if (!confirm(']] .. translate("Are you sure? All traffic history will be permanently deleted.") .. [[')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>]]
|
||||
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
|
||||
end
|
||||
|
||||
sys.call("/etc/init.d/vnstat start 2>/dev/null")
|
||||
m.message = translate("vnStat database has been reset successfully for all interfaces.")
|
||||
end
|
||||
|
||||
elseif active_tab == "about" then
|
||||
local about = m:section(SimpleSection, translate("About"))
|
||||
about.template = "netstat/about"
|
||||
|
||||
else -- usage tab (default)
|
||||
local usage = m:section(SimpleSection, translate("Network Usage"))
|
||||
usage.template = "netstat/usage"
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,86 @@
|
||||
<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;">
|
||||
<%:Frontend for traffic monitoring and usage charts on OpenWrt.%>
|
||||
</p>
|
||||
|
||||
<div class="netstat-about-grid">
|
||||
<div class="netstat-about-row">
|
||||
<span class="netstat-about-label"><%:Developer%></span>
|
||||
<strong>NoobLk</strong>
|
||||
</div>
|
||||
|
||||
<div class="netstat-about-row">
|
||||
<span class="netstat-about-label"><%:Repository%></span>
|
||||
<a href="https://github.com/nooblk-98/luci-app-netstat"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
github.com/nooblk-98/luci-app-netstat
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="netstat-about-row">
|
||||
<span class="netstat-about-label"><%:Contributors%></span>
|
||||
<a href="https://github.com/nooblk-98/luci-app-netstat/graphs/contributors"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<%:View on GitHub%>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="netstat-about-row">
|
||||
<span class="netstat-about-label"><%:License%></span>
|
||||
<span>GPL-3.0</span>
|
||||
</div>
|
||||
|
||||
<div class="netstat-about-row">
|
||||
<span class="netstat-about-label"><%:Issues / Support%></span>
|
||||
<a href="https://github.com/nooblk-98/luci-app-netstat/issues"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<%:Open an issue%>
|
||||
</a>
|
||||
</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;">
|
||||
<%: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;
|
||||
}
|
||||
|
||||
.netstat-about-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.netstat-about-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.netstat-about-label {
|
||||
min-width: 110px;
|
||||
flex-shrink: 0;
|
||||
color: #6b7280;
|
||||
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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<%
|
||||
local http = require "luci.http"
|
||||
local dispatcher = require "luci.dispatcher"
|
||||
|
||||
local active_tab = http.formvalue("tab") or "usage"
|
||||
local base_url = dispatcher.build_url("admin", "tools", "netstat")
|
||||
|
||||
local function tab_url(tab) return base_url .. "?tab=" .. tab end
|
||||
|
||||
local tabs_def = {
|
||||
{ id="usage", label=translate("Network Usage") },
|
||||
{ id="settings", label=translate("Settings") },
|
||||
{ id="about", label=translate("About") },
|
||||
}
|
||||
|
||||
%>
|
||||
|
||||
<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>
|
||||
|
||||
<input type="hidden" name="tab" value="<%=active_tab%>" />
|
||||
@@ -0,0 +1,293 @@
|
||||
<%
|
||||
local sys = require "luci.sys"
|
||||
local http = require "luci.http"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
|
||||
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 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
|
||||
|
||||
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 cur_iface = http.formvalue("iface") or saved_iface or ifaces[1] or ""
|
||||
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 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)
|
||||
end
|
||||
%>
|
||||
|
||||
<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;">
|
||||
<%:Usage is displayed using the Interface and View Mode selected in the Settings tab.%>
|
||||
</p>
|
||||
|
||||
<% if cur_iface ~= "" and #stats > 0 then %>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="netstat-summary-item netstat-summary-total">
|
||||
<span class="netstat-summary-label"><%:Total%>:</span>
|
||||
<strong><%=fmt_bytes(total_rx + total_tx)%></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-badge"><%=cur_iface%></span>
|
||||
</div>
|
||||
<canvas id="trafficChart" class="netstat-chart-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<script src="/luci-static/resources/netstat/chart.js"></script>
|
||||
<script src="/luci-static/resources/netstat/chartjs-plugin-datalabels"></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)';
|
||||
|
||||
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 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;
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
function rxGradient() {
|
||||
var g = ctx.createLinearGradient(0, 0, canvas.width || 600, 0);
|
||||
g.addColorStop(0, '#86efac');
|
||||
g.addColorStop(1, '#15803d');
|
||||
return g;
|
||||
}
|
||||
|
||||
function fmtBytes(v) {
|
||||
if (v >= 1099511627776) return (v/1099511627776).toFixed(2) + ' TB';
|
||||
if (v >= 1073741824) return (v/1073741824).toFixed(2) + ' GB';
|
||||
if (v >= 1048576) return (v/1048576).toFixed(2) + ' MB';
|
||||
if (v >= 1024) return (v/1024).toFixed(2) + ' KB';
|
||||
return v + ' B';
|
||||
}
|
||||
|
||||
var maxVal = Math.max.apply(null, rxData.concat(txData).concat([1]));
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '<%:Download (RX)%>',
|
||||
data: rxData,
|
||||
backgroundColor: rxData.map(function() { return rxGradient(); }),
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
anchor: 'center',
|
||||
align: 'center',
|
||||
font: { size: 11, weight: '600' },
|
||||
formatter: fmtBytes,
|
||||
display: function(ctx) {
|
||||
return maxVal > 0 && ctx.dataset.data[ctx.dataIndex] / maxVal > 0.08;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '<%:Upload (TX)%>',
|
||||
data: txData,
|
||||
backgroundColor: '#f59e0b',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
datalabels: {
|
||||
color: textColor,
|
||||
anchor: 'end',
|
||||
align: 'right',
|
||||
font: { size: 11, weight: '600' },
|
||||
formatter: fmtBytes,
|
||||
display: function(ctx) {
|
||||
return maxVal > 0 && ctx.dataset.data[ctx.dataIndex] / maxVal > 0.02;
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 400 },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: textColor,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(ctx) { return ' ' + fmtBytes(ctx.raw); }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
callback: fmtBytes,
|
||||
maxTicksLimit: 6
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor, autoSkip: false }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<% elseif #ifaces == 0 then %>
|
||||
<div class="netstat-empty">
|
||||
<span class="netstat-empty-icon">📡</span>
|
||||
<%:No vnStat interfaces were found. Make sure vnStat has been initialized.%>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="netstat-empty">
|
||||
<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. Try again in a few minutes.%>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,250 @@
|
||||
<%+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%>
|
||||
Reference in new issue
Block a user