mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 01:44:16 +08:00
⛄ Sync 2026-10-05 06:17:58
This commit is contained in:
1 parent
c0e26fb987
commit
4f24db0fde
7 files changed
+1299
-3
No files matched your search
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=luci-app-netstat
|
PKG_NAME:=luci-app-netstat
|
||||||
PKG_VERSION:=1.1.5
|
PKG_VERSION:=1.1.5
|
||||||
PKG_RELEASE:=2
|
PKG_RELEASE:=3
|
||||||
|
|
||||||
PKG_MAINTAINER:=NoobLK <liyanagelsofficial@gmail.com>
|
PKG_MAINTAINER:=NoobLK <liyanagelsofficial@gmail.com>
|
||||||
PKG_LICENSE:=GPL-3.0
|
PKG_LICENSE:=GPL-3.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"admin/status/netstat": {
|
"admin/tools/netstat": {
|
||||||
"title": "Netstat",
|
"title": "Netstat",
|
||||||
"order": 20,
|
"order": 20,
|
||||||
"action": {
|
"action": {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
LUCI_TITLE:=LuCI Status Pages
|
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
|
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_BUILD_DEPENDS:=iwinfo
|
||||||
PKG_LICENSE:=Apache-2.0
|
PKG_LICENSE:=Apache-2.0
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user