mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 11:43:56 +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,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
old-src
|
||||
.claude
|
||||
@@ -0,0 +1,23 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-netstat
|
||||
PKG_VERSION:=1.1.5
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_MAINTAINER:=NoobLK <liyanagelsofficial@gmail.com>
|
||||
PKG_LICENSE:=GPL-3.0
|
||||
|
||||
LUCI_TITLE:=NET Stats
|
||||
LUCI_DESCRIPTION:=This LuCI app provides net statistic functionality in a web interface.
|
||||
LUCI_DEPENDS:=+vnstat +luci-compat
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
define Package/luci-app-netstat/install
|
||||
$(CP) ./files/* $(1)/
|
||||
# vnstat package owns /etc/config/vnstat on newer OpenWrt versions.
|
||||
# Remove it here to avoid file ownership conflicts if present in any tree.
|
||||
$(RM) $(1)/etc/config/vnstat
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,luci-app-netstat))
|
||||
@@ -0,0 +1,159 @@
|
||||
# luci-app-netstat
|
||||
|
||||
<div align="center">
|
||||
|
||||
**A professional network statistics application for OpenWrt LuCI**
|
||||
|
||||
[](LICENSE)
|
||||

|
||||
|
||||
[Features](#features) • [Installation](#installation) • [Usage](#usage) • [Development](#development) • [License](#license)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||

|
||||
|
||||
|
||||
|
||||
**luci-app-netstat** is a modern, feature-rich LuCI application that provides comprehensive network traffic statistics and monitoring for OpenWrt routers. It leverages `vnstat` for accurate traffic monitoring and presents the data through an intuitive web interface.
|
||||
|
||||
The application offers real-time network statistics, historical data visualization, and detailed traffic analysis to help you understand your network usage patterns.
|
||||
|
||||
## Features
|
||||
|
||||
✨ **Core Features:**
|
||||
- 📊 **Real-time Network Monitoring** - Track network traffic with live statistics
|
||||
- 📈 **Traffic Visualization** - Interactive charts powered by Chart.js
|
||||
- 📅 **Historical Data** - Monitor trends over days, weeks, months, and years
|
||||
- 🎨 **Dark Mode Support** - Professional dark theme for comfortable usage
|
||||
- 📱 **Responsive Design** - Works seamlessly on desktop and mobile devices
|
||||
- ⚙️ **Easy Configuration** - Simple UCI-based configuration system
|
||||
- 🔄 **Automatic Updates** - Uses vnstat for continuous data collection
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenWrt with LuCI installed
|
||||
- `vnstat` package (required dependency)
|
||||
- Modern web browser (Chrome, Firefox, Safari, Edge)
|
||||
|
||||
## Installation
|
||||
|
||||
### Automatic Installation
|
||||
|
||||
Clone the repository and install using the OpenWrt build system:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dotycat/luci-app-netstat.git
|
||||
cd luci-app-netstat
|
||||
make
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest `.ipk` package from [Releases](../../releases)
|
||||
2. Upload to your router via SCP:
|
||||
```bash
|
||||
scp luci-app-netstat_*.ipk root@<router-ip>:/tmp/
|
||||
```
|
||||
3. Install via SSH:
|
||||
```bash
|
||||
ssh root@<router-ip>
|
||||
opkg install /tmp/luci-app-netstat_*.ipk
|
||||
```
|
||||
4. Navigate to **LuCI Web Interface** → **Status** → **Network Statistics**
|
||||
|
||||
## Configuration
|
||||
|
||||
### UCI Configuration
|
||||
|
||||
The application configuration is managed via UCI. Edit `/etc/config/netstats` to customize:
|
||||
|
||||
```
|
||||
config netstat
|
||||
option enabled '1'
|
||||
option interface 'wan'
|
||||
option update_interval '60'
|
||||
```
|
||||
|
||||
### vnstat Integration
|
||||
|
||||
Ensure vnstat is properly configured and running:
|
||||
|
||||
```bash
|
||||
opkg install vnstat
|
||||
service vnstat start
|
||||
service vnstat enable
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Dashboard
|
||||
|
||||
1. Log into your OpenWrt LuCI web interface
|
||||
2. Navigate to **Status** → **Network Statistics**
|
||||
3. View traffic statistics for your network interfaces
|
||||
|
||||
### Available Data
|
||||
|
||||
- **Current Usage** - Real-time traffic rates
|
||||
- **Daily Statistics** - Traffic breakdown by day
|
||||
- **Monthly Analysis** - Monthly usage overview
|
||||
|
||||
## Dark / Light Theme Switching
|
||||
|
||||
The NoobWRT theme stores the user's theme preference in **`localStorage`** under the key `luci-theme` (`'dark'` or `'light'`). When dark mode is active the theme also adds the class `dark-theme` to `document.body`.
|
||||
|
||||
`tabs.htm` (rendered on every Netstat tab) reads this value at page load:
|
||||
|
||||
```js
|
||||
var isDark = localStorage.getItem('luci-theme') === 'dark'
|
||||
|| document.body.classList.contains('dark-theme');
|
||||
```
|
||||
|
||||
If dark mode is detected, a `<style>` block is injected into the page with `!important` overrides for every `.netstat-*` class. This covers:
|
||||
|
||||
| Selector | Dark value |
|
||||
|---|---|
|
||||
| `.netstat-dashboard` | `#1a1d20` background |
|
||||
| `.netstat-summary` | `#111315` background |
|
||||
| `.netstat-chart-wrap` | `#1a1d20` background |
|
||||
| `.netstat-about-card` | `#1a1d20` background |
|
||||
| `.netstat-chart-title`, `.netstat-summary-item` | `#f3f4f6` text |
|
||||
| `.netstat-summary-label`, `.netstat-about-label` | `#9ca3af` text |
|
||||
| `.netstat-about-row a` | `#60a5fa` link color |
|
||||
|
||||
The same flag (`window._netstatDark`) is passed to Chart.js so axis labels and grid lines also use dark-friendly colors.
|
||||
|
||||
No server-side detection or separate CSS files are needed — everything is driven by the single `localStorage` value the theme toggle button already sets.
|
||||
|
||||
## Credits
|
||||
|
||||
- **Original Development**: [SMALLPROGRAM](https://github.com/smallprogram)
|
||||
- **Maintenance**: [nooblk-98](https://github.com/nooblk-98/)
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to:
|
||||
- Report bugs and issues
|
||||
- Submit feature requests
|
||||
- Create pull requests with improvements
|
||||
- Improve documentation
|
||||
|
||||
## Support
|
||||
|
||||
For support, issues, or questions:
|
||||
- Open an [Issue](../../issues) on GitHub
|
||||
- Check existing [Discussions](../../discussions)
|
||||
- Contact: liyanagelsofficial@gmail.com
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Made with ❤️ for the OpenWrt Community**
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
config config
|
||||
option prefer 'wwan0_1'
|
||||
option backend 'normal'
|
||||
|
||||
@@ -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%>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"admin/tools/netstat": {
|
||||
"title": "Netstat",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "cbi",
|
||||
"path": "netstat/config"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-netstat" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"luci-app-netstat": {
|
||||
"description": "Grant access to LuCI Netstat app",
|
||||
"read": {
|
||||
"uci": [ "netstats" ]
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "netstats" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*!
|
||||
* Chart.js v4.5.0
|
||||
* https://www.chartjs.org
|
||||
* (c) 2025 Chart.js Contributors
|
||||
* Released under the MIT License
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?x(n,a,s):e[t]=g(a)}function x(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function b(t,e){return x(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?b(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function J(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return Z(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...eLine truncated
|
||||
/*!
|
||||
* @kurkle/color v0.3.2
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2023 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
|
||||
//# sourceMappingURL=chart.umd.min.js.map
|
||||
@@ -0,0 +1,7 @@
|
||||
/*!
|
||||
* chartjs-plugin-datalabels v2.2.0
|
||||
* https://chartjs-plugin-datalabels.netlify.app
|
||||
* (c) 2017-2022 chartjs-plugin-datalabels contributors
|
||||
* Released under the MIT license
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("chart.js/helpers"),require("chart.js")):"function"==typeof define&&define.amd?define(["chart.js/helpers","chart.js"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).ChartDataLabels=e(t.Chart.helpers,t.Chart)}(this,(function(t,e){"use strict";var r=function(){if("undefined"!=typeof window){if(window.devicePixelRatio)return window.devicePixelRatio;var t=window.screen;if(t)return(t.deviceXDPI||1)/(t.logicalXDPI||1)}return 1}(),a=function(e){var r,a=[];for(e=[].concat(e);e.length;)"string"==typeof(r=e.pop())?a.unshift.apply(a,r.split("\n")):Array.isArray(r)?e.push.apply(e,r):t.isNullOrUndef(e)||a.unshift(""+r);return a},o=function(t,e,r){var a,o=[].concat(e),n=o.length,i=t.font,l=0;for(t.font=r.string,a=0;a<n;++a)l=Math.max(t.measureText(o[a]).width,l);return t.font=i,{height:n*r.lineHeight,width:l}},n=function(t,e,r){return Math.max(t,Math.min(e,r))},i=function(t,e){var r,a,o,n,i=t.slice(),l=[];for(r=0,o=e.length;r<o;++r)n=e[r],-1===(a=i.indexOf(n))?l.push([n,1]):i.splice(a,1);for(r=0,o=i.length;r<o;++r)l.push([i[r],-1]);return l};function l(t,e){var r=e.x,a=e.y;if(null===r)return{x:0,y:-1};if(null===a)return{x:1,y:0};var o=t.x-r,n=t.y-a,i=Math.sqrt(o*o+n*n);return{x:i?o/i:0,y:i?n/i:-1}}function s(t,e,r){var a=0;return t<r.left?a|=1:t>r.right&&(a|=2),e<r.top?a|=8:e>r.bottom&&(a|=4),a}function u(t,e){var r,a,o=e.anchor,n=t;return e.clamp&&(n=function(t,e){for(var r,a,o,n=t.x0,i=t.y0,l=t.x1,u=t.y1,d=s(n,i,e),c=s(l,u,e);d|c&&!(d&c);)8&(r=d||c)?(a=n+(l-n)*(e.top-i)/(u-i),o=e.top):4&r?(a=n+(l-n)*(e.bottom-i)/(u-i),o=e.bottom):2&r?(o=i+(u-i)*(e.right-n)/(l-n),a=e.right):1&r&&(o=i+(u-i)*(e.left-n)/(l-n),a=e.left),r===d?d=s(n=a,i=o,e):c=s(l=a,u=o,e);return{x0:n,x1:l,y0:i,y1:u}}(n,e.area)),"start"===o?(r=n.x0,a=n.y0):"end"===o?(r=n.x1,a=n.y1):(r=(n.x0+n.x1)/2,a=(n.y0+n.y1)/2),function(t,e,r,a,o){switch(o){case"center":r=a=0;break;case"bottom":r=0,a=1;break;case"right":r=1,a=0;break;case"left":r=-1,a=0;break;case"top":r=0,a=-1;break;case"start":r=-r,a=-a;break;case"end":break;default:o*=Math.PI/180,r=Math.cos(o),a=Math.sin(o)}return{x:t,y:e,vx:r,vy:a}}(r,a,t.vx,t.vy,e.align)}var d=function(t,e){var r=(t.startAngle+t.endAngle)/2,a=Math.cos(r),o=Math.sin(r),n=t.innerRadius,i=t.outerRadius;return u({x0:t.x+a*n,y0:t.y+o*n,x1:t.x+a*i,y1:t.y+o*i,vx:a,vy:o},e)},c=function(t,e){var r=l(t,e.origin),a=r.x*t.options.radius,o=r.y*t.options.radius;return u({x0:t.x-a,y0:t.y-o,x1:t.x+a,y1:t.y+o,vx:r.x,vy:r.y},e)},h=function(t,e){var r=l(t,e.origin),a=t.x,o=t.y,n=0,i=0;return t.horizontal?(a=Math.min(t.x,t.base),n=Math.abs(t.base-t.x)):(o=Math.min(t.y,t.base),i=Math.abs(t.base-t.y)),u({x0:a,y0:o+i,x1:a+n,y1:o,vx:r.x,vy:r.y},e)},f=function(t,e){var r=l(t,e.origin);return u({x0:t.x,y0:t.y,x1:t.x+(t.width||0),y1:t.y+(t.height||0),vx:r.x,vy:r.y},e)},x=function(t){return Math.round(t*r)/r};function y(t,e){var r=e.chart.getDatasetMeta(e.datasetIndex).vScale;if(!r)return null;if(void 0!==r.xCenter&&void 0!==r.yCenter)return{x:r.xCenter,y:r.yCenter};var a=r.getBasePixel();return t.horizontal?{x:a,y:null}:{x:null,y:a}}function v(t,e,r){var a=r.backgroundColor,o=r.borderColor,n=r.borderWidth;(a||o&&n)&&(t.beginPath(),function(t,e,r,a,o,n){var i=Math.PI/2;if(n){var l=Math.min(n,o/2,a/2),s=e+l,u=r+l,d=e+a-l,c=r+o-l;t.moveTo(e,u),s<d&&u<c?(t.arc(s,u,l,-Math.PI,-i),t.arc(d,u,l,-i,0),t.arc(d,c,l,0,i),t.arc(s,c,l,i,Math.PI)):s<d?(t.moveTo(s,r),t.arc(d,u,l,-i,i),t.arc(s,u,l,i,Math.PI+i)):u<c?(t.arc(s,u,l,-Math.PI,0),t.arc(s,c,l,0,Math.PI)):t.arc(s,u,l,-Math.PI,Math.PI),t.closePath(),t.moveTo(e,r)}else t.rect(e,r,a,o)}(t,x(e.x)+n/2,x(e.y)+n/2,x(e.w)-n,x(e.h)-n,r.borderRadius),t.closePath(),a&&(t.fillStyle=a,t.fill()),o&&n&&(t.strokeStyle=o,t.lineWidth=n,t.lineJoin="miter",t.stroke()))}function b(t,e,r){var a=t.shadowBlur,o=r.stroked,n=x(r.x),i=x(r.y),l=x(r.w);o&&t.strokeText(e,n,i,l),r.filled&&(a&&o&&(t.shadowBlur=0),t.fillText(e,n,i,l),a&&o&&(t.shadowBlur=a))}var _=function(t,e,r,a){var o=this;o._config=t,o._index=a,o._model=null,o._rects=null,o._ctx=e,o._el=r};t.merge(_.prototype,{_modelize:function(r,a,n,i){var l,s=this,u=s._index,x=t.toFont(t.resolve([n.font,{}],i,u)),v=t.resolve([n.color,e.defaults.color],i,u);return{align:t.resolve([n.align,"center"],i,u),anchor:t.resolve([n.anchor,"center"],i,u),area:i.chart.chartArea,backgroundColor:t.resolve([n.backgroundColor,null],i,u),borderColor:t.resolve([n.borderColor,null],i,u),borderRadius:t.resolve([n.borderRadius,0],i,u),borderWidth:t.resolve([n.borderWidth,0],i,u),clamp:t.resolve([n.clamp,!1],i,u),clip:t.resolve([n.clip,!1],i,u),color:v,display:r,font:x,lines:a,offset:t.resolve([n.offset,4],i,u),opacity:t.resolve([n.opacity,1],i,u),origin:y(s._el,i),padding:t.toPadding(t.resolve([n.padding,4],i,u)),positioner:(l=s._el,l instanceof e.ArcElement?d:l instanceof e.PointElement?c:l instanceof e.BarElement?h:f),rotation:t.resolve([n.rotation,0],i,u)*(Math.PI/180),size:o(s._ctx,a,x),textAlign:t.resolve([nLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2,5.27L3.28,4L20,20.72L18.73,22L15.65,18.92C14.5,19.3 13.28,19.5 12,19.5C7,19.5 2.73,16.39 1,12C1.69,10.24 2.79,8.69 4.19,7.46L2,5.27M12,9A3,3 0 0,1 15,12C15,12.35 14.94,12.69 14.83,13L11,9.17C11.31,9.06 11.65,9 12,9M12,4.5C17,4.5 21.27,7.61 23,12C22.18,14.08 20.79,15.88 19,17.19L17.58,15.76C18.94,14.82 20.06,13.54 20.82,12C19.17,8.64 15.76,6.5 12,6.5C10.91,6.5 9.84,6.68 8.84,7L7.3,5.47C8.74,4.85 10.33,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C12.69,17.5 13.37,17.43 14,17.29L11.72,15C10.29,14.85 9.15,13.71 9,12.28L5.6,8.87C4.61,9.72 3.78,10.78 3.18,12Z" /></svg>
|
||||
|
After Width: | Height: | Size: 644 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C21.27,16.39 17,19.5 12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C15.76,17.5 19.17,15.36 20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12Z" /></svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,110 @@
|
||||
/* netstat-usage.css */
|
||||
.netstat-dashboard {
|
||||
border: 1px solid #dfe3eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.netstat-summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #dfe3eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.netstat-summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.netstat-summary-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.netstat-summary-dot.rx { background: #16a34a; }
|
||||
.netstat-summary-dot.tx { background: #d97706; }
|
||||
|
||||
.netstat-summary-label {
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.netstat-summary-total {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.netstat-chart-canvas {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.netstat-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.netstat-empty-icon {
|
||||
font-size: 36px;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/* ── netstat.css (light theme) ── */
|
||||
|
||||
/* CSS custom properties – override all the scattered !important chains */
|
||||
:root {
|
||||
--ns-bg: #ffffff;
|
||||
--ns-border: #dfe3eb;
|
||||
--ns-text: #111111;
|
||||
--ns-muted: #4b5563;
|
||||
--ns-number: #111111;
|
||||
--ns-unit: #2a2a2a;
|
||||
--ns-label: #3a3a3a;
|
||||
--ns-box-shadow: rgba(0,0,0,0.05);
|
||||
--ns-dl-color: #1f7a3a;
|
||||
--ns-ul-color: #1c63b8;
|
||||
--ns-total-color: #222222;
|
||||
--ns-status-up: #1e6b2f;
|
||||
--ns-status-down: #b32020;
|
||||
--ns-sep: #111111;
|
||||
--ns-summary-bg: #f7f9fc;
|
||||
--ns-badge-bg: #e0f2fe;
|
||||
--ns-badge-text: #0369a1;
|
||||
--ns-mem-color: #7c3aed;
|
||||
--ns-temp-color: #dc2626;
|
||||
--ns-disk-color: #0891b2;
|
||||
--ns-bar-bg: #e5e7eb;
|
||||
}
|
||||
|
||||
/* ── Widget wrapper ─────────────────────────────────────────────────────────── */
|
||||
.stats-grid.netstat-wrap {
|
||||
padding: 10px 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
font-family: "Trebuchet MS", "Lucida Sans Unicode", sans-serif !important;
|
||||
max-width: 1240px !important;
|
||||
width: 100% !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.netstat-row {
|
||||
display: flex !important;
|
||||
gap: 10px !important;
|
||||
align-items: stretch !important;
|
||||
justify-content: center !important;
|
||||
flex-wrap: nowrap !important; /* single row on desktop */
|
||||
max-width: 1280px !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
/* ── Stat boxes ──────────────────────────────────────────────────────────────── */
|
||||
.netstat-box {
|
||||
flex: 1 1 0 !important; /* all boxes share space equally */
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
padding: 12px 10px !important;
|
||||
background: var(--ns-bg) !important;
|
||||
border: 2px solid var(--ns-border) !important;
|
||||
border-radius: 12px !important;
|
||||
text-align: center !important;
|
||||
box-shadow: 0 2px 6px var(--ns-box-shadow) !important;
|
||||
transition: border-color 0.15s, box-shadow 0.15s !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: center !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.netstat-box:hover {
|
||||
border-color: #94a3b8 !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.10) !important;
|
||||
}
|
||||
|
||||
.netstat-center {
|
||||
flex: 1.2 1 0 !important; /* status card slightly wider */
|
||||
min-width: 0 !important;
|
||||
border-style: dashed !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.netstat-info-card {
|
||||
flex: 1.2 1 0 !important; /* info cards slightly wider for pill bars */
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Typography ─────────────────────────────────────────────────────────────── */
|
||||
.netstat-number {
|
||||
font-weight: 800 !important;
|
||||
font-size: 24px !important;
|
||||
color: var(--ns-number) !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.netstat-unit {
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !important;
|
||||
color: var(--ns-unit) !important;
|
||||
letter-spacing: 0.2px !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.netstat-label {
|
||||
margin-top: 6px !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--ns-label) !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.6px !important;
|
||||
}
|
||||
|
||||
/* ── Center (status) card ─────────────────────────────────────────────────── */
|
||||
.netstat-center-title {
|
||||
font-size: 11px !important;
|
||||
font-weight: 800 !important;
|
||||
color: var(--ns-muted) !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.8px !important;
|
||||
}
|
||||
|
||||
.netstat-center-status {
|
||||
font-size: 15px !important;
|
||||
font-weight: 800 !important;
|
||||
color: var(--ns-status-up) !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.netstat-center-sep {
|
||||
height: 1px !important;
|
||||
background: var(--ns-sep) !important;
|
||||
opacity: 0.15 !important;
|
||||
margin: 8px 0 !important;
|
||||
}
|
||||
|
||||
.netstat-center-ip {
|
||||
font-size: 12px !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--ns-text) !important;
|
||||
font-family: "Lucida Console", "Courier New", monospace !important;
|
||||
word-break: break-all !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── Accent colours ─────────────────────────────────────────────────────────── */
|
||||
.netstat-box.is-download .netstat-number { color: var(--ns-dl-color) !important; }
|
||||
.netstat-box.is-upload .netstat-number { color: var(--ns-ul-color) !important; }
|
||||
.netstat-box.is-total .netstat-number { color: var(--ns-total-color) !important; }
|
||||
.netstat-box.is-downloaded .netstat-number { color: var(--ns-dl-color) !important; }
|
||||
.netstat-box.is-uploaded .netstat-number { color: var(--ns-ul-color) !important; }
|
||||
.netstat-box.is-down .netstat-center-status { color: var(--ns-status-down) !important; }
|
||||
|
||||
/* ── Downloaded / Uploaded stat box accent borders ─────────────────────────── */
|
||||
.netstat-box.is-downloaded {
|
||||
border-color: color-mix(in srgb, var(--ns-dl-color) 35%, var(--ns-border)) !important;
|
||||
}
|
||||
.netstat-box.is-uploaded {
|
||||
border-color: color-mix(in srgb, var(--ns-ul-color) 35%, var(--ns-border)) !important;
|
||||
}
|
||||
|
||||
/* Fallback for browsers without color-mix */
|
||||
@supports not (color: color-mix(in srgb, red 50%, blue)) {
|
||||
.netstat-box.is-downloaded { border-color: #a8d5b5 !important; }
|
||||
.netstat-box.is-uploaded { border-color: #a8c8f0 !important; }
|
||||
}
|
||||
|
||||
/* ── Stat box value row ──────────────────────────────────────────────────────── */
|
||||
.ns-stat-value-row {
|
||||
display: flex !important;
|
||||
align-items: baseline !important;
|
||||
justify-content: center !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
/* ── Rate box layout ────────────────────────────────────────────────────────── */
|
||||
.ns-spark {
|
||||
width: 90px !important;
|
||||
height: 28px !important;
|
||||
margin: 0 auto 4px !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.ns-rate-row {
|
||||
display: flex !important;
|
||||
align-items: baseline !important;
|
||||
justify-content: center !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.ns-peak {
|
||||
font-size: 10px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--ns-muted) !important;
|
||||
margin-top: 2px !important;
|
||||
letter-spacing: 0.2px !important;
|
||||
}
|
||||
|
||||
/* ── Trend arrows ───────────────────────────────────────────────────────────── */
|
||||
.ns-trend {
|
||||
font-size: 13px !important;
|
||||
font-weight: 900 !important;
|
||||
line-height: 1 !important;
|
||||
margin-left: 2px !important;
|
||||
}
|
||||
|
||||
.ns-trend-up { color: #1f7a3a !important; }
|
||||
.ns-trend-down { color: #b32020 !important; }
|
||||
.ns-trend-flat { color: #9ca3af !important; font-size: 8px !important; }
|
||||
|
||||
/* ── Info card ───────────────────────────────────────────────────────────────── */
|
||||
.netstat-info-card {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: center !important;
|
||||
gap: 0 !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.ns-info-row {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
padding: 4px 0 !important;
|
||||
}
|
||||
|
||||
.ns-info-icon {
|
||||
font-size: 18px !important;
|
||||
line-height: 1 !important;
|
||||
width: 24px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.ns-info-block {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.ns-info-value {
|
||||
font-size: 15px !important;
|
||||
font-weight: 800 !important;
|
||||
color: var(--ns-number) !important;
|
||||
line-height: 1 !important;
|
||||
font-family: "Lucida Console", "Courier New", monospace !important;
|
||||
}
|
||||
|
||||
.ns-info-label {
|
||||
font-size: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--ns-muted) !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
.ns-info-block-grow {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.ns-info-top-row {
|
||||
display: flex !important;
|
||||
align-items: baseline !important;
|
||||
justify-content: space-between !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.ns-info-sublabel {
|
||||
font-size: 10px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--ns-muted) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
/* ── Info tag label (RAM / CPU TEMP / STORAGE) ──────────────────────────────── */
|
||||
.ns-info-tag {
|
||||
font-size: 9px !important;
|
||||
font-weight: 800 !important;
|
||||
color: var(--ns-muted) !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.7px !important;
|
||||
margin-top: 3px !important;
|
||||
}
|
||||
|
||||
/* ── Pill bar (RAM / STORAGE) ────────────────────────────────────────────────── */
|
||||
.ns-pill-bar {
|
||||
position: relative !important;
|
||||
height: 18px !important;
|
||||
background: var(--ns-bar-bg) !important;
|
||||
border-radius: 20px !important;
|
||||
margin-top: 4px !important;
|
||||
overflow: hidden !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ns-pill-fill {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
height: 100% !important;
|
||||
border-radius: 20px !important;
|
||||
transition: width 0.5s ease !important;
|
||||
opacity: 0.30 !important;
|
||||
}
|
||||
|
||||
.ns-pill-text {
|
||||
position: relative !important;
|
||||
z-index: 1 !important;
|
||||
display: block !important;
|
||||
text-align: center !important;
|
||||
font-size: 9.5px !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--ns-text) !important;
|
||||
line-height: 18px !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
padding: 0 6px !important;
|
||||
}
|
||||
|
||||
#ns-mem-bar { background: var(--ns-mem-color) !important; }
|
||||
#ns-disk-bar { background: var(--ns-disk-color) !important; }
|
||||
|
||||
/* ── Temperature value colour ───────────────────────────────────────────────── */
|
||||
.ns-temp-val { color: var(--ns-temp-color) !important; }
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Tablet: wrap into 2 columns */
|
||||
@media (max-width: 900px) {
|
||||
.netstat-row {
|
||||
flex-wrap: wrap !important;
|
||||
padding: 0 10px !important;
|
||||
gap: 10px !important;
|
||||
}
|
||||
|
||||
.netstat-box,
|
||||
.netstat-center,
|
||||
.netstat-info-card {
|
||||
flex: 1 1 calc(50% - 10px) !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* Status card full width, placed after rate+total boxes */
|
||||
.netstat-center {
|
||||
flex: 1 1 100% !important;
|
||||
order: 5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: 2 compact columns, explicit order matching screenshot */
|
||||
@media (max-width: 600px) {
|
||||
.netstat-row {
|
||||
flex-wrap: wrap !important;
|
||||
padding: 0 8px !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.netstat-box,
|
||||
.netstat-center,
|
||||
.netstat-info-card {
|
||||
flex: 1 1 calc(50% - 8px) !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
padding: 10px 8px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* Mobile order: download | upload → status(full) → info | temp → downloaded | uploaded */
|
||||
.netstat-box.is-download { order: 1 !important; }
|
||||
.netstat-box.is-upload { order: 2 !important; }
|
||||
.netstat-center { order: 3 !important; flex: 1 1 100% !important; }
|
||||
.netstat-info-card { order: 4 !important; }
|
||||
.netstat-box.is-downloaded { order: 5 !important; }
|
||||
.netstat-box.is-uploaded { order: 6 !important; }
|
||||
|
||||
.netstat-number { font-size: 20px !important; }
|
||||
.netstat-unit { font-size: 11px !important; }
|
||||
|
||||
.ns-spark {
|
||||
width: 100% !important;
|
||||
max-width: 80px !important;
|
||||
}
|
||||
|
||||
.netstat-info-card {
|
||||
padding: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small phones: single column */
|
||||
@media (max-width: 360px) {
|
||||
.netstat-box,
|
||||
.netstat-center,
|
||||
.netstat-info-card {
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* ── netstat_dark.css (dark theme) ── */
|
||||
/* Imports light theme then overrides only the colour tokens.
|
||||
This eliminates ~120 lines of duplicated rules. */
|
||||
@import url("netstat.css");
|
||||
|
||||
:root {
|
||||
--ns-bg: #0f1113;
|
||||
--ns-border: #374151;
|
||||
--ns-text: #f4f4f4;
|
||||
--ns-muted: #9ca3af;
|
||||
--ns-number: #f4f4f4;
|
||||
--ns-unit: #d6d6d6;
|
||||
--ns-label: #c8c8c8;
|
||||
--ns-box-shadow: rgba(0,0,0,0.35);
|
||||
--ns-dl-color: #7fdc98;
|
||||
--ns-ul-color: #7db8ff;
|
||||
--ns-total-color: #f0f0f0;
|
||||
--ns-status-up: #7fdc98;
|
||||
--ns-status-down: #ff7d7d;
|
||||
--ns-sep: #e6e6e6;
|
||||
--ns-summary-bg: #1a1d20;
|
||||
--ns-badge-bg: #1e3a5f;
|
||||
--ns-badge-text: #93c5fd;
|
||||
--ns-mem-color: #a78bfa;
|
||||
--ns-temp-color: #f87171;
|
||||
--ns-disk-color: #22d3ee;
|
||||
--ns-bar-bg: #374151;
|
||||
}
|
||||
|
||||
/* Dark-specific trend arrow colours */
|
||||
.ns-trend-up { color: #7fdc98 !important; }
|
||||
.ns-trend-down { color: #ff7d7d !important; }
|
||||
|
||||
/* Dark-specific hover (slightly lighter border) */
|
||||
.netstat-box:hover {
|
||||
border-color: #6b7280 !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
let prev = {};
|
||||
let last_time = Date.now();
|
||||
let _pollAdded = false;
|
||||
let _container = null;
|
||||
|
||||
// Peak speeds per direction (bits/s)
|
||||
let peakRx = 0;
|
||||
let peakTx = 0;
|
||||
|
||||
// Previous speeds for trending arrows
|
||||
let lastRxSpeed = 0;
|
||||
let lastTxSpeed = 0;
|
||||
|
||||
// Sparkline history – last 30 samples (~60 s at 2 s poll)
|
||||
const SPARK_LEN = 30;
|
||||
let sparkRx = new Array(SPARK_LEN).fill(0);
|
||||
let sparkTx = new Array(SPARK_LEN).fill(0);
|
||||
|
||||
// ─── CSS Loader (theme-aware, no busy-poll) ───────────────────────────────────
|
||||
(function loadDynamicCSS() {
|
||||
let loadedCss = null;
|
||||
|
||||
function isDarkMode() {
|
||||
try {
|
||||
const bg = getComputedStyle(document.body).backgroundColor;
|
||||
if (!bg || bg === 'transparent') return false;
|
||||
const rgb = bg.match(/\d+/g);
|
||||
if (!rgb || rgb.length < 3) return false;
|
||||
const [r, g, b] = rgb.map(Number);
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 < 100;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function applyCSS() {
|
||||
const file = isDarkMode() ? 'netstat_dark.css' : 'netstat.css';
|
||||
if (loadedCss === file) return;
|
||||
loadedCss = file;
|
||||
|
||||
document.querySelectorAll('link[data-netstat-css]').forEach(l => l.remove());
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.setAttribute('data-netstat-css', '1');
|
||||
link.href = '/luci-static/resources/netstat/' + file + '?t=' + Date.now();
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => setTimeout(applyCSS, 50));
|
||||
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyCSS);
|
||||
}
|
||||
|
||||
const mo = new MutationObserver(applyCSS);
|
||||
const waitForBody = setInterval(() => {
|
||||
if (!document.body) return;
|
||||
clearInterval(waitForBody);
|
||||
mo.observe(document.body, { attributes: true, attributeFilter: ['style', 'class'] });
|
||||
applyCSS();
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function parseNetdev(raw) {
|
||||
const stats = {};
|
||||
for (const line of raw.split('\n')) {
|
||||
const m = line.trim().match(/^([^:]+):\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
const vals = m[2].trim().split(/\s+/).map(v => parseInt(v) || 0);
|
||||
if (vals.length >= 9)
|
||||
stats[m[1].trim()] = { rx: vals[0], tx: vals[8] };
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
for (const i of ['pppoe-wan','lte0','usb0','wan','eth1','tun0','wg0'])
|
||||
if (stats[i]) return i;
|
||||
|
||||
return Object.keys(stats).find(k => k !== 'lo') || '';
|
||||
}
|
||||
|
||||
function formatRate(bps) {
|
||||
const units = ['Bps','Kbps','Mbps','Gbps'];
|
||||
let i = 0;
|
||||
while (bps >= 1000 && i < units.length - 1) { bps /= 1000; i++; }
|
||||
return { number: bps.toFixed(i > 0 ? 1 : 0), unit: units[i] + '/s' };
|
||||
}
|
||||
|
||||
function formatBytes(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';
|
||||
}
|
||||
|
||||
function formatUptime(sec) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm ' + (sec % 60) + 's';
|
||||
}
|
||||
|
||||
// trend arrow: +1 = rising, -1 = falling, 0 = stable
|
||||
function trendArrow(current, previous) {
|
||||
const ratio = previous > 0 ? current / previous : 1;
|
||||
if (ratio > 1.10) return { char: '▲', cls: 'ns-trend-up' };
|
||||
if (ratio < 0.90) return { char: '▼', cls: 'ns-trend-down' };
|
||||
return { char: '●', cls: 'ns-trend-flat' };
|
||||
}
|
||||
|
||||
// ─── Sparkline SVG ────────────────────────────────────────────────────────────
|
||||
function drawSparkline(values, colorVar) {
|
||||
const W = 90, H = 28;
|
||||
const max = Math.max(...values, 1);
|
||||
const step = W / (values.length - 1);
|
||||
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = H - (v / max) * (H - 2) - 1;
|
||||
return x.toFixed(1) + ',' + y.toFixed(1);
|
||||
}).join(' ');
|
||||
|
||||
// filled area path
|
||||
const areaPoints =
|
||||
'0,' + H + ' ' + points + ' ' + (W) + ',' + H;
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', W);
|
||||
svg.setAttribute('height', H);
|
||||
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
||||
svg.style.display = 'block';
|
||||
svg.style.overflow = 'visible';
|
||||
|
||||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
|
||||
grad.setAttribute('id', 'sg-' + colorVar.replace(/[^a-z]/gi,''));
|
||||
grad.setAttribute('x1', '0'); grad.setAttribute('y1', '0');
|
||||
grad.setAttribute('x2', '0'); grad.setAttribute('y2', '1');
|
||||
|
||||
const s1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
||||
s1.setAttribute('offset', '0%');
|
||||
s1.setAttribute('stop-color', 'var(' + colorVar + ')');
|
||||
s1.setAttribute('stop-opacity', '0.35');
|
||||
const s2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
||||
s2.setAttribute('offset', '100%');
|
||||
s2.setAttribute('stop-color', 'var(' + colorVar + ')');
|
||||
s2.setAttribute('stop-opacity', '0.03');
|
||||
|
||||
grad.appendChild(s1); grad.appendChild(s2);
|
||||
defs.appendChild(grad); svg.appendChild(defs);
|
||||
|
||||
const area = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||
area.setAttribute('points', areaPoints);
|
||||
area.setAttribute('fill', 'url(#sg-' + colorVar.replace(/[^a-z]/gi,'') + ')');
|
||||
svg.appendChild(area);
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
||||
line.setAttribute('points', points);
|
||||
line.setAttribute('fill', 'none');
|
||||
line.setAttribute('stroke', 'var(' + colorVar + ')');
|
||||
line.setAttribute('stroke-width', '1.5');
|
||||
line.setAttribute('stroke-linejoin', 'round');
|
||||
line.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(line);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// Replace sparkline SVG inside a container (identified by class)
|
||||
function updateSparkline(container, cls, values, colorVar) {
|
||||
const box = container.querySelector('.' + cls);
|
||||
if (!box) return;
|
||||
const wrap = box.querySelector('.ns-spark');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(drawSparkline(values, colorVar));
|
||||
}
|
||||
|
||||
// ─── Fetch with timeout ───────────────────────────────────────────────────────
|
||||
function fetchWithTimeout(url, ms) {
|
||||
const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const tid = ctrl ? setTimeout(() => ctrl.abort(), ms) : null;
|
||||
return fetch(url, ctrl ? { signal: ctrl.signal } : {})
|
||||
.then(r => r.json())
|
||||
.finally(() => tid && clearTimeout(tid));
|
||||
}
|
||||
|
||||
// ─── DOM builders ─────────────────────────────────────────────────────────────
|
||||
function createRateBox(label, value, unit, extraClass, colorVar, sparkValues, peak) {
|
||||
const cls = 'netstat-box ' + extraClass;
|
||||
const peakFmt = formatRate(peak * 8);
|
||||
|
||||
const box = E('div', { class: cls }, [
|
||||
E('div', { class: 'ns-spark' }), // sparkline goes here
|
||||
E('div', { class: 'ns-rate-row' }, [
|
||||
E('div', { class: 'netstat-number' }, value),
|
||||
E('div', { class: 'netstat-unit' }, unit)
|
||||
]),
|
||||
E('div', { class: 'ns-peak' }, _('Peak') + ': ' + peakFmt.number + ' ' + peakFmt.unit),
|
||||
E('div', { class: 'netstat-label' }, label)
|
||||
]);
|
||||
|
||||
// draw initial sparkline
|
||||
const wrap = box.querySelector('.ns-spark');
|
||||
wrap.appendChild(drawSparkline(sparkValues, colorVar));
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
function createStatBox(label, value, unit, extraClass) {
|
||||
const cls = 'netstat-box' + (extraClass ? ' ' + extraClass : '');
|
||||
const box = E('div', { class: cls }, [
|
||||
E('div', { class: 'ns-stat-value-row' }, [
|
||||
E('div', { class: 'netstat-number' }, value),
|
||||
unit ? E('div', { class: 'netstat-unit' }, unit) : null
|
||||
].filter(Boolean)),
|
||||
E('div', { class: 'netstat-label' }, label)
|
||||
]);
|
||||
return box;
|
||||
}
|
||||
|
||||
|
||||
function createStatusCard(status, ip) {
|
||||
const up = status === 'Connected';
|
||||
return E('div', { class: 'netstat-box netstat-center ' + (up ? 'is-up' : 'is-down') }, [
|
||||
E('div', { class: 'netstat-center-title' }, _('Internet')),
|
||||
E('div', { class: 'netstat-center-status' }, up ? _('Connected') : _('Disconnected')),
|
||||
E('div', { class: 'netstat-center-sep' }),
|
||||
E('div', { class: 'netstat-center-title' }, _('IP')),
|
||||
E('div', { class: 'netstat-center-ip' }, ip || 'N/A'),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Inline SVG icons ─────────────────────────────────────────────────────────
|
||||
function svgIcon(path, color) {
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(ns, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('width', '16');
|
||||
svg.setAttribute('height', '16');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', color || 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.style.flexShrink = '0';
|
||||
svg.innerHTML = path;
|
||||
return svg;
|
||||
}
|
||||
|
||||
// clock icon
|
||||
function iconUptime() {
|
||||
return svgIcon('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>', 'var(--ns-muted)');
|
||||
}
|
||||
// database/memory icon
|
||||
function iconMem() {
|
||||
return svgIcon('<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/>', 'var(--ns-mem-color)');
|
||||
}
|
||||
// thermometer/temperature icon
|
||||
function iconTemp() {
|
||||
return svgIcon('<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>', 'var(--ns-temp-color)');
|
||||
}
|
||||
// hard-drive/disk icon
|
||||
function iconDisk() {
|
||||
return svgIcon('<line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/>', 'var(--ns-disk-color)');
|
||||
}
|
||||
|
||||
// Pill-style bar: filled background + "used MB / total MB (pct%)" text overlay
|
||||
function makePillBar(pct, used, total, fillId, textId) {
|
||||
const clampedPct = Math.min(100, Math.max(0, pct));
|
||||
const fill = E('div', { class: 'ns-pill-fill', id: fillId });
|
||||
fill.style.width = clampedPct + '%';
|
||||
const label = E('span', { class: 'ns-pill-text', id: textId },
|
||||
used + ' / ' + total + ' (' + clampedPct + '%)');
|
||||
return E('div', { class: 'ns-pill-bar' }, [fill, label]);
|
||||
}
|
||||
|
||||
// Card 1: RAM + Storage pill bars
|
||||
function createInfoCard(memPct, memUsed, memTotal, diskPct, diskUsed, diskTotal) {
|
||||
const card = E('div', { class: 'netstat-box netstat-info-card' });
|
||||
|
||||
// RAM row
|
||||
const memRow = E('div', { class: 'ns-info-row' });
|
||||
memRow.appendChild(iconMem());
|
||||
const memBlock = E('div', { class: 'ns-info-block ns-info-block-grow' });
|
||||
memBlock.appendChild(E('div', { class: 'ns-info-tag' }, _('RAM')));
|
||||
memBlock.appendChild(makePillBar(memPct,
|
||||
memUsed + ' MB', memTotal + ' MB', 'ns-mem-bar', 'ns-mem-sub'));
|
||||
memRow.appendChild(memBlock);
|
||||
card.appendChild(memRow);
|
||||
|
||||
card.appendChild(E('div', { class: 'netstat-center-sep' }));
|
||||
|
||||
// Storage row
|
||||
const diskRow = E('div', { class: 'ns-info-row' });
|
||||
diskRow.appendChild(iconDisk());
|
||||
const diskBlock = E('div', { class: 'ns-info-block ns-info-block-grow' });
|
||||
diskBlock.appendChild(E('div', { class: 'ns-info-tag' }, _('STORAGE')));
|
||||
diskBlock.appendChild(makePillBar(diskPct,
|
||||
diskUsed + ' MB', diskTotal + ' MB', 'ns-disk-bar', 'ns-disk-sub'));
|
||||
diskRow.appendChild(diskBlock);
|
||||
card.appendChild(diskRow);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Card 2: CPU Temperature + Uptime
|
||||
function createTempCard(cpuTemp, uptime) {
|
||||
const card = E('div', { class: 'netstat-box netstat-info-card' });
|
||||
|
||||
// Temp row
|
||||
const tempRow = E('div', { class: 'ns-info-row' });
|
||||
tempRow.appendChild(iconTemp());
|
||||
const tempBlock = E('div', { class: 'ns-info-block ns-info-block-grow' });
|
||||
const tempVal = cpuTemp != null ? cpuTemp + '°C' : 'N/A';
|
||||
tempBlock.appendChild(E('div', { class: 'ns-info-value ns-temp-val', id: 'ns-temp-val' }, tempVal));
|
||||
tempBlock.appendChild(E('div', { class: 'ns-info-tag' }, _('CPU TEMP')));
|
||||
tempRow.appendChild(tempBlock);
|
||||
card.appendChild(tempRow);
|
||||
|
||||
card.appendChild(E('div', { class: 'netstat-center-sep' }));
|
||||
|
||||
// Uptime row
|
||||
const uptimeRow = E('div', { class: 'ns-info-row' });
|
||||
uptimeRow.appendChild(iconUptime());
|
||||
const uptimeBlock = E('div', { class: 'ns-info-block ns-info-block-grow' });
|
||||
uptimeBlock.appendChild(E('div', { class: 'ns-info-value', id: 'ns-uptime-val' }, uptime));
|
||||
uptimeBlock.appendChild(E('div', { class: 'ns-info-tag' }, _('UPTIME')));
|
||||
uptimeRow.appendChild(uptimeBlock);
|
||||
card.appendChild(uptimeRow);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ─── In-place DOM patch ───────────────────────────────────────────────────────
|
||||
function patchText(el, sel, val) {
|
||||
const n = el && el.querySelector(sel);
|
||||
if (n && n.textContent !== val) n.textContent = val;
|
||||
}
|
||||
|
||||
function updateContainer(container, data, dt) {
|
||||
const stats = data.stats || {};
|
||||
const preferred = data.preferred || [];
|
||||
const iface = getBestWAN(stats, preferred);
|
||||
const curr = stats[iface] || { rx: 0, tx: 0 };
|
||||
curr.rx = parseInt(curr.rx) || 0;
|
||||
curr.tx = parseInt(curr.tx) || 0;
|
||||
|
||||
const ps = prev[iface] || { rx: curr.rx, tx: curr.tx };
|
||||
const rxSpeed = Math.max(0, (curr.rx - ps.rx) / dt);
|
||||
const txSpeed = Math.max(0, (curr.tx - ps.tx) / dt);
|
||||
prev[iface] = { rx: curr.rx, tx: curr.tx };
|
||||
|
||||
// peaks
|
||||
if (rxSpeed > peakRx) peakRx = rxSpeed;
|
||||
if (txSpeed > peakTx) peakTx = txSpeed;
|
||||
|
||||
// sparkline ring buffers
|
||||
sparkRx.push(rxSpeed); if (sparkRx.length > SPARK_LEN) sparkRx.shift();
|
||||
sparkTx.push(txSpeed); if (sparkTx.length > SPARK_LEN) sparkTx.shift();
|
||||
|
||||
// trend
|
||||
const tRx = trendArrow(rxSpeed, lastRxSpeed);
|
||||
const tTx = trendArrow(txSpeed, lastTxSpeed);
|
||||
lastRxSpeed = rxSpeed;
|
||||
lastTxSpeed = txSpeed;
|
||||
|
||||
const rxRate = formatRate(rxSpeed * 8);
|
||||
const txRate = formatRate(txSpeed * 8);
|
||||
const totalRx = formatBytes(curr.rx).split(' ');
|
||||
const totalTx = formatBytes(curr.tx).split(' ');
|
||||
const peakRxFmt = formatRate(peakRx * 8);
|
||||
const peakTxFmt = formatRate(peakTx * 8);
|
||||
|
||||
const boxes = container.querySelectorAll('.netstat-box');
|
||||
if (boxes.length < 7) return false; // stale, rebuild
|
||||
|
||||
// download rate box
|
||||
patchText(container, '.is-download .netstat-number', rxRate.number);
|
||||
patchText(container, '.is-download .netstat-unit', rxRate.unit);
|
||||
patchText(container, '.is-download .ns-peak',
|
||||
_('Peak') + ': ' + peakRxFmt.number + ' ' + peakRxFmt.unit);
|
||||
|
||||
// trend on download
|
||||
const dlTrend = container.querySelector('.is-download .ns-trend');
|
||||
if (dlTrend) { dlTrend.textContent = tRx.char; dlTrend.className = 'ns-trend ' + tRx.cls; }
|
||||
|
||||
// upload rate box
|
||||
patchText(container, '.is-upload .netstat-number', txRate.number);
|
||||
patchText(container, '.is-upload .netstat-unit', txRate.unit);
|
||||
patchText(container, '.is-upload .ns-peak',
|
||||
_('Peak') + ': ' + peakTxFmt.number + ' ' + peakTxFmt.unit);
|
||||
|
||||
// trend on upload
|
||||
const ulTrend = container.querySelector('.is-upload .ns-trend');
|
||||
if (ulTrend) { ulTrend.textContent = tTx.char; ulTrend.className = 'ns-trend ' + tTx.cls; }
|
||||
|
||||
// sparklines
|
||||
updateSparkline(container, 'is-download', sparkRx, '--ns-dl-color');
|
||||
updateSparkline(container, 'is-upload', sparkTx, '--ns-ul-color');
|
||||
|
||||
// center status card
|
||||
const center = container.querySelector('.netstat-center');
|
||||
if (center) {
|
||||
const up = (data.status || '') === 'Connected';
|
||||
center.className = 'netstat-box netstat-center ' + (up ? 'is-up' : 'is-down');
|
||||
patchText(center, '.netstat-center-status', up ? _('Connected') : _('Disconnected'));
|
||||
patchText(center, '.netstat-center-ip', data.ip || 'N/A');
|
||||
}
|
||||
|
||||
// totals
|
||||
const totals = container.querySelectorAll('.is-total');
|
||||
if (totals[0]) {
|
||||
patchText(totals[0], '.netstat-number', totalRx[0]);
|
||||
patchText(totals[0], '.netstat-unit', totalRx[1] || '');
|
||||
}
|
||||
if (totals[1]) {
|
||||
patchText(totals[1], '.netstat-number', totalTx[0]);
|
||||
patchText(totals[1], '.netstat-unit', totalTx[1] || '');
|
||||
}
|
||||
|
||||
// uptime in status card
|
||||
patchText(container, '#ns-uptime-val', formatUptime(data.uptime || 0));
|
||||
|
||||
// temp
|
||||
const tempVal = data.cpu_temp != null ? data.cpu_temp + '°C' : 'N/A';
|
||||
patchText(container, '#ns-temp-val', tempVal);
|
||||
|
||||
// pill bar: update fill width + text label
|
||||
const memPct = data.mem_pct || 0;
|
||||
const diskPct = data.disk_pct || 0;
|
||||
|
||||
const memFill = container.querySelector('#ns-mem-bar');
|
||||
if (memFill) memFill.style.width = Math.min(100, Math.max(0, memPct)) + '%';
|
||||
patchText(container, '#ns-mem-sub',
|
||||
(data.mem_used || 0) + ' MB / ' + (data.mem_total || 0) + ' MB (' + memPct + '%)');
|
||||
|
||||
const diskFill = container.querySelector('#ns-disk-bar');
|
||||
if (diskFill) diskFill.style.width = Math.min(100, Math.max(0, diskPct)) + '%';
|
||||
patchText(container, '#ns-disk-sub',
|
||||
(data.disk_used || 0) + ' MB / ' + (data.disk_total || 0) + ' MB (' + diskPct + '%)');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Cached public IP (refreshed every 60 s, not on every 2 s poll) ──────────
|
||||
let _cachedIp = 'N/A';
|
||||
let _lastIpFetch = 0;
|
||||
const IP_REFRESH_MS = 300000;
|
||||
|
||||
function maybeRefreshIp() {
|
||||
const now = Date.now();
|
||||
if (now - _lastIpFetch < IP_REFRESH_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; })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ─── Main baseclass ───────────────────────────────────────────────────────────
|
||||
return baseclass.extend({
|
||||
title: _(''),
|
||||
|
||||
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)
|
||||
.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] : []
|
||||
};
|
||||
})
|
||||
.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: [] }));
|
||||
},
|
||||
|
||||
render(data) {
|
||||
const now = Date.now();
|
||||
const dt = Math.max(0.1, (now - last_time) / 1000);
|
||||
last_time = now;
|
||||
|
||||
const stats = data.stats || {};
|
||||
const preferred = data.preferred || [];
|
||||
|
||||
if (!stats || typeof stats !== 'object' || Array.isArray(stats))
|
||||
return E('div', { style: 'padding:20px;text-align:center;color:#999;font-size:13px' },
|
||||
_('Loading network stats...'));
|
||||
|
||||
const iface = getBestWAN(stats, preferred);
|
||||
const curr = stats[iface] || { rx: 0, tx: 0 };
|
||||
curr.rx = parseInt(curr.rx) || 0;
|
||||
curr.tx = parseInt(curr.tx) || 0;
|
||||
|
||||
const ps = prev[iface] || { rx: curr.rx, tx: curr.tx };
|
||||
const rxSpeed = Math.max(0, (curr.rx - ps.rx) / dt);
|
||||
const txSpeed = Math.max(0, (curr.tx - ps.tx) / dt);
|
||||
prev[iface] = { rx: curr.rx, tx: curr.tx };
|
||||
|
||||
if (rxSpeed > peakRx) peakRx = rxSpeed;
|
||||
if (txSpeed > peakTx) peakTx = txSpeed;
|
||||
|
||||
sparkRx.push(rxSpeed); if (sparkRx.length > SPARK_LEN) sparkRx.shift();
|
||||
sparkTx.push(txSpeed); if (sparkTx.length > SPARK_LEN) sparkTx.shift();
|
||||
|
||||
const tRx = trendArrow(rxSpeed, lastRxSpeed);
|
||||
const tTx = trendArrow(txSpeed, lastTxSpeed);
|
||||
lastRxSpeed = rxSpeed;
|
||||
lastTxSpeed = txSpeed;
|
||||
|
||||
const rxRate = formatRate(rxSpeed * 8);
|
||||
const txRate = formatRate(txSpeed * 8);
|
||||
const totalRx = formatBytes(curr.rx).split(' ');
|
||||
const totalTx = formatBytes(curr.tx).split(' ');
|
||||
|
||||
// Build download box manually to inject trend badge
|
||||
const dlBox = createRateBox(_('download'), rxRate.number, rxRate.unit,
|
||||
'is-download', '--ns-dl-color', [...sparkRx], peakRx);
|
||||
const dlTrend = E('span', { class: 'ns-trend ' + tRx.cls }, tRx.char);
|
||||
dlBox.querySelector('.ns-rate-row').appendChild(dlTrend);
|
||||
|
||||
const ulBox = createRateBox(_('upload'), txRate.number, txRate.unit,
|
||||
'is-upload', '--ns-ul-color', [...sparkTx], peakTx);
|
||||
const ulTrend = E('span', { class: 'ns-trend ' + tTx.cls }, tTx.char);
|
||||
ulBox.querySelector('.ns-rate-row').appendChild(ulTrend);
|
||||
|
||||
// Desktop order: DOWNLOAD | UPLOAD | TEMP | INTERNET STATUS | DISK+RAM | DOWNLOADED | UPLOADED
|
||||
const row = E('div', { class: 'netstat-row' }, [
|
||||
dlBox,
|
||||
ulBox,
|
||||
createTempCard(data.cpu_temp != null ? data.cpu_temp : null,
|
||||
formatUptime(data.uptime || 0)),
|
||||
createStatusCard(data.status || 'Disconnected', data.ip),
|
||||
createInfoCard(data.mem_pct || 0, data.mem_used || 0, data.mem_total || 0,
|
||||
data.disk_pct || 0, data.disk_used || 0, data.disk_total || 0),
|
||||
createStatBox(_('downloaded'), totalRx[0], totalRx[1], 'is-total is-downloaded'),
|
||||
createStatBox(_('uploaded'), totalTx[0], totalTx[1], 'is-total is-uploaded'),
|
||||
]);
|
||||
|
||||
const container = E('div', { class: 'stats-grid netstat-wrap' }, row);
|
||||
_container = container;
|
||||
|
||||
if (!_pollAdded) {
|
||||
_pollAdded = true;
|
||||
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)
|
||||
.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;
|
||||
|
||||
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);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 2);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 318 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,268 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: luci-app-netstat\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: files/usr/lib/lua/luci/controller/netstat.lua:4
|
||||
msgid "Tools"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/controller/netstat.lua:5
|
||||
msgid "Netstat"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:6
|
||||
msgid "Configure traffic monitoring and view usage history from a single page."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:15
|
||||
msgid "Monitoring Settings"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:20
|
||||
msgid "Traffic Backend"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:22
|
||||
msgid "Normal (Real-time)"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:23
|
||||
msgid "vnStat (Historical, Delayed)"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:24
|
||||
msgid ""
|
||||
"Normal reads live counters from /proc/net/dev. "
|
||||
"vnStat reads the vnStat database which updates on its own schedule."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:30
|
||||
msgid "View Mode"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:32
|
||||
msgid "Daily Usage"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:33
|
||||
msgid "Monthly Usage"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:34
|
||||
msgid "Default view shown on the Network Usage tab."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:37
|
||||
msgid "Preferred Interface"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:38
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:43
|
||||
msgid "Auto detect"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:54
|
||||
msgid "vnStat Database"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:63
|
||||
msgid "Location: /etc/vnstat/"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:69
|
||||
msgid "Reset Historical Data"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:70
|
||||
msgid "Reset Database"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:72
|
||||
msgid ""
|
||||
"<strong style='color:#b91c1c;'>Warning:</strong> "
|
||||
"This permanently deletes all stored traffic history for every interface."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:87
|
||||
msgid "Are you sure? All traffic history will be permanently deleted."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:109
|
||||
msgid "vnStat database has been reset successfully for all interfaces."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:113
|
||||
msgid "About"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:117
|
||||
#: files/usr/lib/lua/luci/view/netstat/tabs.htm:11
|
||||
msgid "Network Usage"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/tabs.htm:12
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:2
|
||||
msgid "LuCI Netstat"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:4
|
||||
msgid "Frontend for traffic monitoring and usage charts on OpenWrt."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:9
|
||||
msgid "Developer"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:14
|
||||
msgid "Repository"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:22
|
||||
msgid "Contributors"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:25
|
||||
msgid "View on GitHub"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:30
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:35
|
||||
msgid "Issues / Support"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:38
|
||||
msgid "Open an issue"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:45
|
||||
msgid "Made with"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:45
|
||||
msgid "for the OpenWrt community"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:100
|
||||
msgid "Monthly"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:103
|
||||
msgid "Daily"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:118
|
||||
msgid "Usage is displayed using the Interface and View Mode selected in the Settings tab."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:126
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:131
|
||||
msgid "Upload"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:135
|
||||
msgid "Total"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:142
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:203
|
||||
msgid "Download (RX)"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:220
|
||||
msgid "Upload (TX)"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:282
|
||||
msgid "No vnStat interfaces were found. Make sure vnStat has been initialized."
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:287
|
||||
msgid "No data available for"
|
||||
msgstr ""
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:289
|
||||
msgid "vnStat may still be collecting its first data. Try again in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:241
|
||||
msgid "Internet"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:242
|
||||
msgid "Connected"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:242
|
||||
msgid "Disconnected"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:244
|
||||
msgid "IP"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:497
|
||||
msgid "Loading network stats..."
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:526
|
||||
msgid "download"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:531
|
||||
msgid "upload"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:545
|
||||
msgid "downloaded"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:546
|
||||
msgid "uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:214
|
||||
msgid "Peak"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:301
|
||||
msgid "RAM"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:313
|
||||
msgid "STORAGE"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:332
|
||||
msgid "CPU TEMP"
|
||||
msgstr ""
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:343
|
||||
msgid "UPTIME"
|
||||
msgstr ""
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
@@ -0,0 +1,275 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Project-Id-Version: luci-app-netstat\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language: zh_CN\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Poedit 3.5\n"
|
||||
|
||||
#: files/usr/lib/lua/luci/controller/netstat.lua:4
|
||||
msgid "Tools"
|
||||
msgstr "工具"
|
||||
|
||||
#: files/usr/lib/lua/luci/controller/netstat.lua:5
|
||||
msgid "Netstat"
|
||||
msgstr "网络状态"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:6
|
||||
msgid "Configure traffic monitoring and view usage history from a single page."
|
||||
msgstr "配置流量监控,并在单个页面查看使用历史。"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:15
|
||||
msgid "Monitoring Settings"
|
||||
msgstr "监控设置"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:20
|
||||
msgid "Traffic Backend"
|
||||
msgstr "流量后端"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:22
|
||||
msgid "Normal (Real-time)"
|
||||
msgstr "普通(实时)"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:23
|
||||
msgid "vnStat (Historical, Delayed)"
|
||||
msgstr "vnStat(历史,延迟)"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:24
|
||||
msgid ""
|
||||
"Normal reads live counters from /proc/net/dev. "
|
||||
"vnStat reads the vnStat database which updates on its own schedule."
|
||||
msgstr ""
|
||||
"普通模式从 /proc/net/dev 读取实时计数器。"
|
||||
"vnStat 模式从 vnStat 数据库读取,其更新有自己的计划。"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:30
|
||||
msgid "View Mode"
|
||||
msgstr "视图模式"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:32
|
||||
msgid "Daily Usage"
|
||||
msgstr "每日用量"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:33
|
||||
msgid "Monthly Usage"
|
||||
msgstr "每月用量"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:34
|
||||
msgid "Default view shown on the Network Usage tab."
|
||||
msgstr "在网络用量标签页上显示的默认视图。"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:37
|
||||
msgid "Preferred Interface"
|
||||
msgstr "首选接口"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:38
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
"要跟踪的 WAN 接口。留空以自动检测。<br>"
|
||||
"<strong>提示:</strong>如果您的 WAN 使用动态名称(例如 wwan0_1),"
|
||||
"留空时将自动检测。"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:43
|
||||
msgid "Auto detect"
|
||||
msgstr "自动检测"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:54
|
||||
msgid "vnStat Database"
|
||||
msgstr "vnStat 数据库"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:63
|
||||
msgid "Location: /etc/vnstat/"
|
||||
msgstr "位置:/etc/vnstat/"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:69
|
||||
msgid "Reset Historical Data"
|
||||
msgstr "重置历史数据"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:70
|
||||
msgid "Reset Database"
|
||||
msgstr "重置数据库"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:72
|
||||
msgid ""
|
||||
"<strong style='color:#b91c1c;'>Warning:</strong> "
|
||||
"This permanently deletes all stored traffic history for every interface."
|
||||
msgstr ""
|
||||
"<strong style='color:#b91c1c;'>警告:</strong>"
|
||||
"这将永久删除所有接口的所有已存储流量历史记录。"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:87
|
||||
msgid "Are you sure? All traffic history will be permanently deleted."
|
||||
msgstr "您确定吗?所有流量历史记录将被永久删除。"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:109
|
||||
msgid "vnStat database has been reset successfully for all interfaces."
|
||||
msgstr "vnStat 数据库已成功为所有接口重置。"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:113
|
||||
msgid "About"
|
||||
msgstr "关于"
|
||||
|
||||
#: files/usr/lib/lua/luci/model/cbi/netstat/config.lua:117
|
||||
#: files/usr/lib/lua/luci/view/netstat/tabs.htm:11
|
||||
msgid "Network Usage"
|
||||
msgstr "网络用量"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/tabs.htm:12
|
||||
msgid "Settings"
|
||||
msgstr "设置"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:2
|
||||
msgid "LuCI Netstat"
|
||||
msgstr "LuCI 网络状态"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:4
|
||||
msgid "Frontend for traffic monitoring and usage charts on OpenWrt."
|
||||
msgstr "OpenWrt 上用于流量监控和用量图表的前端。"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:9
|
||||
msgid "Developer"
|
||||
msgstr "开发者"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:14
|
||||
msgid "Repository"
|
||||
msgstr "代码仓库"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:22
|
||||
msgid "Contributors"
|
||||
msgstr "贡献者"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:25
|
||||
msgid "View on GitHub"
|
||||
msgstr "在 GitHub 上查看"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:30
|
||||
msgid "License"
|
||||
msgstr "许可证"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:35
|
||||
msgid "Issues / Support"
|
||||
msgstr "问题 / 支持"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:38
|
||||
msgid "Open an issue"
|
||||
msgstr "提交问题"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:45
|
||||
msgid "Made with"
|
||||
msgstr "用"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/about.htm:45
|
||||
msgid "for the OpenWrt community"
|
||||
msgstr "为 OpenWrt 社区制作"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:100
|
||||
msgid "Monthly"
|
||||
msgstr "每月"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:103
|
||||
msgid "Daily"
|
||||
msgstr "每日"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:118
|
||||
msgid "Usage is displayed using the Interface and View Mode selected in the Settings tab."
|
||||
msgstr "用量根据"设置"选项卡中选择的接口和视图模式显示。"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:126
|
||||
msgid "Download"
|
||||
msgstr "下载"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:131
|
||||
msgid "Upload"
|
||||
msgstr "上传"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:135
|
||||
msgid "Total"
|
||||
msgstr "总计"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:142
|
||||
msgid "Usage"
|
||||
msgstr "用量"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:203
|
||||
msgid "Download (RX)"
|
||||
msgstr "下载(接收)"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:220
|
||||
msgid "Upload (TX)"
|
||||
msgstr "上传(发送)"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:282
|
||||
msgid "No vnStat interfaces were found. Make sure vnStat has been initialized."
|
||||
msgstr "未找到 vnStat 接口。请确保已初始化 vnStat。"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:287
|
||||
msgid "No data available for"
|
||||
msgstr "没有可用数据:"
|
||||
|
||||
#: files/usr/lib/lua/luci/view/netstat/usage.htm:289
|
||||
msgid "vnStat may still be collecting its first data. Try again in a few minutes."
|
||||
msgstr "vnStat 可能仍在收集初始数据,请几分钟后重试。"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:241
|
||||
msgid "Internet"
|
||||
msgstr "互联网"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:242
|
||||
msgid "Connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:242
|
||||
msgid "Disconnected"
|
||||
msgstr "已断开"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:244
|
||||
msgid "IP"
|
||||
msgstr "IP 地址"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:497
|
||||
msgid "Loading network stats..."
|
||||
msgstr "正在加载网络统计..."
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:526
|
||||
msgid "download"
|
||||
msgstr "下载"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:531
|
||||
msgid "upload"
|
||||
msgstr "上传"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:545
|
||||
msgid "downloaded"
|
||||
msgstr "已下载"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:546
|
||||
msgid "uploaded"
|
||||
msgstr "已上传"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:214
|
||||
msgid "Peak"
|
||||
msgstr "峰值"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:301
|
||||
msgid "RAM"
|
||||
msgstr "内存"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:313
|
||||
msgid "STORAGE"
|
||||
msgstr "存储"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:332
|
||||
msgid "CPU TEMP"
|
||||
msgstr "CPU 温度"
|
||||
|
||||
#: files/www/luci-static/resources/view/status/include/08_stats.js:343
|
||||
msgid "UPTIME"
|
||||
msgstr "运行时间"
|
||||
Reference in new issue
Block a user