diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css index bf60edba5..c4f33a1f9 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/css/custom.css @@ -153,7 +153,7 @@ gap: 20px; margin-block: auto; /* Override Footstrap's negative SVG container margins to preserve card padding. */ - margin-inline: 0; + margin-inline: 0 !important; } .dashboard-donut svg { @@ -349,8 +349,8 @@ .dashboard-bars-stack i.dashboard-bar-ok { background: var(--dashboard-success); } .dashboard-bars-stack i.dashboard-bar-mid { background: var(--dashboard-warn); } -.dashboard-bars-stack i.dashboard-bar-up { background: var(--dashboard-series-1); } -.dashboard-bars-stack i.dashboard-bar-down { background: var(--dashboard-series-2); } +.dashboard-bars-stack i.dashboard-bar-up { background: var(--dashboard-series-2); } +.dashboard-bars-stack i.dashboard-bar-down { background: var(--dashboard-series-1); } .dashboard-bars-stack i.dashboard-bar-none { background: var(--dashboard-border); } .dashboard-bars-label { @@ -373,8 +373,8 @@ .dashboard-legend-inline i.dashboard-bar-ok { background: var(--dashboard-success); } .dashboard-legend-inline i.dashboard-bar-mid { background: var(--dashboard-warn); } -.dashboard-legend-inline i.dashboard-bar-up { background: var(--dashboard-series-1); } -.dashboard-legend-inline i.dashboard-bar-down { background: var(--dashboard-series-2); } +.dashboard-legend-inline i.dashboard-bar-up { background: var(--dashboard-series-2); } +.dashboard-legend-inline i.dashboard-bar-down { background: var(--dashboard-series-1); } /* ---- Time series (shares the axis and grid of the bar chart) ---- */ .dashboard-lines-plot { @@ -422,6 +422,11 @@ color: var(--dashboard-text); } +/* ---- WAN traffic: one counter source per plot ---- */ +.dashboard-traffic-source { + min-width: 0; +} + /* ---- Wireless network boxes (LuCI .ifacebox) ---- */ .dashboard-net-grid { display: grid; diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/15_traffic.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/15_traffic.js index 4f74e6174..390f53300 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/15_traffic.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/15_traffic.js @@ -1,81 +1,86 @@ 'use strict'; 'require baseclass'; -'require network'; 'require view.dashboard.lib.charts as charts'; 'require view.dashboard.lib.history as history'; const NET = 4; +function rate(cur, prev, seconds, name, index) { + const a = prev?.[NET]?.[name]; + const b = cur?.[NET]?.[name]; + + if (!Array.isArray(a) || !Array.isArray(b) || !Number.isFinite(seconds) || seconds <= 0) + return null; + + // Older rpcd instances provide only two fields. Crossing to the new + // format, like a change of ifindex, starts a fresh pair of samples. + if (a[2] !== b[2] || a[2] === null || b[2] === null) + return null; + + for (let i = 0; i < 2; i++) + if (!Number.isFinite(a[i]) || !Number.isFinite(b[i]) || a[i] < 0 || b[i] < a[i]) + return null; + + return (b[index] - a[index]) * 8 / seconds; +} + +function selectedRate(cur, prev, seconds, index) { + const current = cur?.[5]?.devices, previous = prev?.[5]?.devices; + if (current?.length !== 1 || previous?.length !== 1 || current[0] !== previous[0]) + return null; + return rate(cur, prev, seconds, current[0], index); +} + +function points(index) { + const points = history.series((cur, prev, seconds) => selectedRate(cur, prev, seconds, index)); + const last = points[points.length - 1]; + if (last && history.now - last.t > history.step * 2.5) + points.push({ t: history.now, v: null }); + return points; +} + return baseclass.extend({ title: _('WAN traffic'), - widgets: [ { id: 'wan', slot: 'charts', title: _('WAN traffic'), order: 10 } ], load() { - return Promise.all([ - network.getWANNetworks(), - network.getWAN6Networks(), - history.load() - ]); + return history.load(); }, - uplink(wan, wan6) { - const pick = list => list.slice().sort((a, b) => a.getMetric() - b.getMetric())[0]; - - return pick(wan) || pick(wan6) || null; - }, - - // Rates come from the byte counters of two samples and the time between - // them on the router's clock. Counters that went backwards, as after the - // interface was brought up again, give no rate and leave a gap. - rates(name, index) { - return history.series((cur, prev, seconds) => { - const a = prev ? prev[NET][name] : null; - const b = cur[NET][name]; - - if (a == null || b == null || b[0] < a[0] || b[1] < a[1]) - return null; - - return (b[index] - a[index]) * 8 / seconds; - }); - }, - - render([wan, wan6]) { - const ifc = this.uplink(wan, wan6); - const dev = ifc ? ifc.getL3Device() : null; - - if (dev == null) - return { charts: [ { id: 'wan', node: charts.card({ title: this.title, body: charts.empty(_('Not connected')) }) } ] }; - - const down = this.rates(dev.getName(), 0); - const up = this.rates(dev.getName(), 1); + render() { + const down = points(0); + const up = points(1); const last = points => points.length ? points[points.length - 1].v : null; const scale = charts.rateScale(Math.max(1, ...down.concat(up).map(p => p.v || 0))); - const names = (ifc.getName() == dev.getName()) ? [ dev.getName() ] : [ ifc.getName(), dev.getName() ]; + const available = down.some(p => p.v != null) || up.some(p => p.v != null); + const current = last(down) != null && last(up) != null; + const sample = history.samples[history.samples.length - 1]; + const device = sample?.[5]?.devices?.[0]; + const body = []; - return { - charts: [ { - id: 'wan', - node: charts.card({ - title: this.title, - desc: names.concat([ scale.unit ]).join(' · '), - body: down.length ? [ - charts.lines({ - span: history.span(), - max: scale.max, - ticks: scale.ticks, - ariaLabel: this.title, - series: [ { values: down, area: true }, { values: up, area: true } ] - }), - charts.legend([ - { className: 'dashboard-series-1', label: _('Down.'), value: charts.formatRate(last(down)) }, - { className: 'dashboard-series-2', label: _('Up.'), value: charts.formatRate(last(up)) } - ]) - ] : charts.empty(history.status()) - }) - } ] - }; + if (available) { + body.push(charts.lines({ + span: history.span(), + max: scale.max, + ticks: scale.ticks, + ariaLabel: this.title, + series: [ { values: down, area: true }, { values: up, area: true } ] + }), charts.legend([ + { className: 'dashboard-series-1', label: _('Download'), value: scale.format(last(down)) }, + { className: 'dashboard-series-2', label: _('Upload'), value: scale.format(last(up)) } + ])); + } + if (!current) + body.push(charts.empty(history.failed || !sample?.[5]?.devices?.length + ? _('No data received') : available ? _('Waiting for a new sample...') : history.status())); + + // No null children: older LuCI DOM helpers render them as literal text. + return { charts: [ { id: 'wan', node: charts.card({ + title: this.title, + desc: device ? device + ' · ' + scale.unit : scale.unit, + body: [ E('section', { 'class': 'dashboard-traffic-source' }, body) ] + }) } ] }; } }); diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js index ebb772d35..4780c6289 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/include/30_wifi.js @@ -165,17 +165,17 @@ return baseclass.extend({ ticks: [ 0, 1, 2, 3, 4 ].map(n => ({ value: scale.step * n, label: scale.format(scale.step * n) })), items: devices.map(device => ({ label: device.hostname.value, - title: '%s · %s %s · %s %s'.format(device.hostname.value, _('Up.'), device.transferred.value.rx, _('Down.'), device.transferred.value.tx) + title: '%s · %s %s · %s %s'.format(device.hostname.value, _('Download'), device.transferred.value.tx, _('Upload'), device.transferred.value.rx) + (device.connected.value ? ' · %s %s'.format(_('Connected'), device.connected.value) : ''), values: [ - { value: device.transferred.value.bytes.rx, className: 'dashboard-bar-up' }, - { value: device.transferred.value.bytes.tx, className: 'dashboard-bar-down' } + { value: device.transferred.value.bytes.tx, className: 'dashboard-bar-down' }, + { value: device.transferred.value.bytes.rx, className: 'dashboard-bar-up' } ] })) }), charts.legend([ - { className: 'dashboard-bar-up', label: _('Up.') }, - { className: 'dashboard-bar-down', label: _('Down.') } + { className: 'dashboard-bar-down', label: _('Download') }, + { className: 'dashboard-bar-up', label: _('Upload') } ]) ] : charts.empty(_('No wireless clients connected')) }); diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js index 96e629fa7..6f0705af9 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/charts.js @@ -36,13 +36,24 @@ function rateUnit(value) { } return baseclass.extend({ - formatRate(value) { + formatRate(value, targetExp) { if (value == null) return '-'; - const exp = rateUnit(value); + const exp = (targetExp != null) ? targetExp : rateUnit(value); + const unit = Math.pow(1000, exp); + const num = value / unit; - return '%.1f %s'.format(value / Math.pow(1000, exp), RATE_UNITS[exp]); + if (num === 0) + return '0 %s'.format(RATE_UNITS[exp]); + + if (num > 0 && num < 0.01 && exp > 0) + return '< 0.01 %s'.format(RATE_UNITS[exp]); + + if (num < 1 && exp > 0) + return '%.2f %s'.format(num, RATE_UNITS[exp]); + + return '%.1f %s'.format(num, RATE_UNITS[exp]); }, rateScale(peak) { @@ -55,7 +66,8 @@ return baseclass.extend({ return { max: step * 4 * unit, unit: RATE_UNITS[exp], - ticks: [ 0, 1, 2, 3, 4 ].map(n => ({ value: step * n * unit, label: String(+(step * n).toFixed(2)) })) + ticks: [ 0, 1, 2, 3, 4 ].map(n => ({ value: step * n * unit, label: String(+(step * n).toFixed(2)) })), + format: value => this.formatRate(value, exp) }; }, diff --git a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/history.js b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/history.js index a198dbc02..2a8bd6bd4 100644 --- a/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/history.js +++ b/luci-mod-dashboard/htdocs/luci-static/resources/view/dashboard/lib/history.js @@ -31,6 +31,7 @@ return baseclass.extend({ this.samples = []; this.id = res.id; + this.now = res.now; this.step = res.step; this.slots = res.slots; this.samples = this.samples.concat(res.samples).filter(sample => sample[TIME] > res.now - res.step * res.slots); @@ -57,7 +58,7 @@ return baseclass.extend({ }, near(prev, cur) { - return (prev != null && cur[TIME] - prev[TIME] <= 2.5 * this.step); + return (prev != null && cur[TIME] > prev[TIME] && cur[TIME] - prev[TIME] <= 2.5 * this.step); }, pair() { diff --git a/luci-mod-dashboard/root/usr/share/rpcd/ucode/luci.dashboard b/luci-mod-dashboard/root/usr/share/rpcd/ucode/luci.dashboard index d87679d9b..e8b3131b0 100644 --- a/luci-mod-dashboard/root/usr/share/rpcd/ucode/luci.dashboard +++ b/luci-mod-dashboard/root/usr/share/rpcd/ucode/luci.dashboard @@ -2,10 +2,58 @@ import { readfile } from 'fs'; import * as uloop from 'uloop'; +import { connect } from 'ubus'; const STEP = 5000, SLOTS = 60; -let ring = [], seq = 0, timer; +let ring = [], seq = 0, timer, selectedDevice; +const bus = connect(); + +// Observe one main-table default-route device. Prefer IPv4, then route metric; +// keep the existing device on equal priority to avoid polling-order switches. +function sources(interfaces, previous) { + let best = null; + for (let iface in interfaces) { + if (!iface.up) + continue; + let name = iface.device; + if (!name || name == 'lo' || substr(name, 0, 1) == '@') + name = iface.l3_device; + if (!name || name == 'lo' || substr(name, 0, 1) == '@') + continue; + + for (let route in iface.route ?? []) { + if (+route.mask != 0) + continue; + const family = route.target == '0.0.0.0' ? 4 : route.target == '::' ? 6 : 0; + if (!family) + continue; + // netifd omits route.table when it is inherited from the interface. + const table = route.table ?? (family == 4 ? iface.ip4table : iface.ip6table); + if (table && table != 254) + continue; + const metric = +(route.metric ?? iface.metric ?? 0); + if (!best || family < best.family || + (family == best.family && (metric < best.metric || + (metric == best.metric && (name == previous || + (best.device != previous && name < best.device)))))) + best = { device: name, family, metric }; + } + } + + // Use the configured carrier (including a VLAN) without walking to its + // physical parent, which may also carry LAN or IPTV traffic. + return best ? [ best.device ] : []; +} + +function traffic_boundary() { + const dump = bus.call('network.interface', 'dump'); + if (type(dump?.interface) != 'array') + return null; + const devices = sources(dump.interface, selectedDevice); + selectedDevice = devices[0]; + return { devices }; +} function now() { const c = clock(true); @@ -19,7 +67,10 @@ const id = trim(readfile('/proc/sys/kernel/random/uuid')); // Counters as the kernel has them. Whoever draws them takes the differences, // over the time between two samples on the clock they were read by. function sample() { - const t = now(), cpus = {}, net = {}; + const cpus = {}, net = {}; + let boundary = null; + try { boundary = traffic_boundary(); } + catch (e) { warn(`luci.dashboard: traffic discovery: ${e}\n`); } for (let line in split(readfile('/proc/stat') ?? '', '\n')) { if (substr(line, 0, 3) != 'cpu') @@ -32,14 +83,26 @@ function sample() { const mem = match(readfile('/proc/meminfo', 256) ?? '', /MemAvailable:\s+(\d+)/); - for (let line in slice(split(readfile('/proc/net/dev') ?? '', '\n'), 2)) { - const fields = split(trim(line), /[: ]+/); + // Only the selected WAN consumes network history. Check its identity + // on both sides of the counter read to detect device recreation. + const name = boundary?.devices?.[0]; + const before = name ? int(readfile(`/sys/class/net/${name}/ifindex`)) : null; + const t = now(); - if (length(fields) > 9 && fields[0] != 'lo') - net[fields[0]] = [ +fields[1], +fields[9] ]; + if (name) { + for (let line in slice(split(readfile('/proc/net/dev') ?? '', '\n'), 2)) { + const fields = split(trim(line), /[: ]+/); + if (length(fields) <= 9 || fields[0] != name) + continue; + + const after = int(readfile(`/sys/class/net/${name}/ifindex`)); + net[name] = [ +fields[1], +fields[9], + after > 0 && after == before ? after : null ]; + break; + } } - push(ring, [ ++seq, t, cpus, mem ? +mem[1] * 1024 : null, net ]); + push(ring, [ ++seq, t, cpus, mem ? +mem[1] * 1024 : null, net, boundary ]); while (length(ring) > SLOTS) shift(ring);