From 4f24db0fde1a20ba1ac0bdce1bd1cb2ad54457aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Oct 2026 06:17:58 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9B=84=20Sync=202026-10-05=2006:17:58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-netstat/Makefile | 2 +- .../share/luci/menu.d/luci-app-netstat.json | 2 +- luci-mod-status/Makefile | 2 +- .../luci-static/resources/netstat/netstat.css | 400 ++++++++++++ .../resources/netstat/netstat_dark.css | 38 ++ .../resources/view/status/include/08_stats.js | 609 ++++++++++++++++++ .../usr/lib/lua/luci/controller/netstat.lua | 249 +++++++ 7 files changed, 1299 insertions(+), 3 deletions(-) create mode 100644 luci-mod-status/htdocs/luci-static/resources/netstat/netstat.css create mode 100644 luci-mod-status/htdocs/luci-static/resources/netstat/netstat_dark.css create mode 100644 luci-mod-status/htdocs/luci-static/resources/view/status/include/08_stats.js create mode 100644 luci-mod-status/root/usr/lib/lua/luci/controller/netstat.lua diff --git a/luci-app-netstat/Makefile b/luci-app-netstat/Makefile index cf4aedd5c..a5c7ec862 100644 --- a/luci-app-netstat/Makefile +++ b/luci-app-netstat/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-netstat PKG_VERSION:=1.1.5 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_MAINTAINER:=NoobLK PKG_LICENSE:=GPL-3.0 diff --git a/luci-app-netstat/files/usr/share/luci/menu.d/luci-app-netstat.json b/luci-app-netstat/files/usr/share/luci/menu.d/luci-app-netstat.json index 186fc73d4..97869bcf9 100644 --- a/luci-app-netstat/files/usr/share/luci/menu.d/luci-app-netstat.json +++ b/luci-app-netstat/files/usr/share/luci/menu.d/luci-app-netstat.json @@ -1,5 +1,5 @@ { - "admin/status/netstat": { + "admin/tools/netstat": { "title": "Netstat", "order": 20, "action": { diff --git a/luci-mod-status/Makefile b/luci-mod-status/Makefile index 253e214ac..f33fefb71 100644 --- a/luci-mod-status/Makefile +++ b/luci-mod-status/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI Status Pages LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket -PKG_RELEASE:=27 +PKG_RELEASE:=28 PKG_BUILD_DEPENDS:=iwinfo PKG_LICENSE:=Apache-2.0 diff --git a/luci-mod-status/htdocs/luci-static/resources/netstat/netstat.css b/luci-mod-status/htdocs/luci-static/resources/netstat/netstat.css new file mode 100644 index 000000000..807c3b83d --- /dev/null +++ b/luci-mod-status/htdocs/luci-static/resources/netstat/netstat.css @@ -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; + } +} diff --git a/luci-mod-status/htdocs/luci-static/resources/netstat/netstat_dark.css b/luci-mod-status/htdocs/luci-static/resources/netstat/netstat_dark.css new file mode 100644 index 000000000..e0e4e76b6 --- /dev/null +++ b/luci-mod-status/htdocs/luci-static/resources/netstat/netstat_dark.css @@ -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; +} diff --git a/luci-mod-status/htdocs/luci-static/resources/view/status/include/08_stats.js b/luci-mod-status/htdocs/luci-static/resources/view/status/include/08_stats.js new file mode 100644 index 000000000..43ee59f81 --- /dev/null +++ b/luci-mod-status/htdocs/luci-static/resources/view/status/include/08_stats.js @@ -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('', 'var(--ns-muted)'); +} +// database/memory icon +function iconMem() { + return svgIcon('', 'var(--ns-mem-color)'); +} +// thermometer/temperature icon +function iconTemp() { + return svgIcon('', 'var(--ns-temp-color)'); +} +// hard-drive/disk icon +function iconDisk() { + return svgIcon('', '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; + } +}); diff --git a/luci-mod-status/root/usr/lib/lua/luci/controller/netstat.lua b/luci-mod-status/root/usr/lib/lua/luci/controller/netstat.lua new file mode 100644 index 000000000..6f87315d8 --- /dev/null +++ b/luci-mod-status/root/usr/lib/lua/luci/controller/netstat.lua @@ -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/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