mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
💋 Sync 2026-10-04 04:16:09
This commit is contained in:
1 parent
f6b2df73ec
commit
f62992fd80
745 files changed
+212416
-1173
No files matched your search
@@ -0,0 +1,49 @@
|
||||
#
|
||||
# Vantage dashboard for LuCI: a live, read-only overview of the device,
|
||||
# its uplink, radios, SSIDs and stations. The only write is the station
|
||||
# alias, stored in /etc/config/vantage (own ACL group).
|
||||
#
|
||||
# This is free software, licensed under the GNU General Public License,
|
||||
# version 3 or (at your option) any later version.
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-vantage
|
||||
PKG_VERSION:=1.0.1
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
LUCI_TITLE:=Vantage dashboard
|
||||
LUCI_DESCRIPTION:=Live network dashboard (landing page under admin/dashboard): device, \
|
||||
uplink, radios, SSIDs and stations with health and experience scores. \
|
||||
Read-only except for station names, which are kept in /etc/config/vantage.
|
||||
# rpcd-mod-iwinfo provides the station lists (iwinfo info/assoclist).
|
||||
# rpcd-mod-ucode runs the app's plugin (/usr/share/rpcd/ucode/luci.vantage:
|
||||
# key-free wireless status, validated alias writes), which imports the uci
|
||||
# and ubus ucode modules; luci-base pulls all three in already, they are
|
||||
# listed because the plugin needs them.
|
||||
# Optional at runtime, degrade when absent: rpcd-mod-rrdns (reverse DNS
|
||||
# names), umdns (mDNS names), hostapd ubus objects (WPS names, client
|
||||
# capabilities).
|
||||
LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-iwinfo +rpcd-mod-ucode +ucode-mod-uci +ucode-mod-ubus
|
||||
LUCI_PKGARCH:=all
|
||||
LUCI_MAINTAINER:=anothaDev <10740588+anothaDev@users.noreply.github.com>
|
||||
LUCI_URL:=https://github.com/anothaDev/luci-theme-vantage
|
||||
|
||||
# app.css uses custom properties, color-mix() and container queries,
|
||||
# which csstidy (a CSS2-era minifier) mangles; ship it byte for byte.
|
||||
LUCI_MINIFY_CSS:=0
|
||||
|
||||
# Language packages (po/<lang>/vantage.po) are luci-i18n-vantage-<lang>
|
||||
# (luci.mk's default name) and carry the package version instead of one
|
||||
# derived from git history.
|
||||
PKG_PO_VERSION:=$(PKG_VERSION)-r$(PKG_RELEASE)
|
||||
|
||||
define Package/luci-app-vantage/conffiles
|
||||
/etc/config/vantage
|
||||
endef
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* Number and text formatting for the Vantage dashboard. Pure functions:
|
||||
no DOM, no rpc. Every function accepts junk (null, strings, NaN) and
|
||||
returns a placeholder instead of "NaN" or "undefined". */
|
||||
|
||||
var DASH = '—';
|
||||
|
||||
function num(v) { if (v == null || v === '') return null; v = +v; return isFinite(v) ? v : null; }
|
||||
|
||||
/* 3 significant digits, at most 2 decimals: 1.23, 12.3, 123 */
|
||||
function sig3(v) {
|
||||
return v >= 100 ? v.toFixed(0) : v >= 10 ? v.toFixed(1) : v.toFixed(2).replace(/\.?0+$/, '') || '0';
|
||||
}
|
||||
|
||||
/* Characters that reorder or hide text (bidi controls, zero-width, C0/C1
|
||||
controls, fillers, tag characters). Client-chosen names and SSIDs may
|
||||
carry them; they become a visible replacement mark. ZWJ/ZWNJ and the
|
||||
emoji presentation selectors are kept. */
|
||||
var UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u115f\u1160\u17b4\u17b5\u180e\u200b\u200e\u200f\u2028-\u202e\u2060-\u206f\u3164\ufe00-\ufe0d\ufeff\uffa0\ufff9-\ufffb]|\uDB40[\uDC00-\uDC7F]/g;
|
||||
|
||||
return baseclass.extend({
|
||||
DASH: DASH,
|
||||
|
||||
/* bits per second -> { v: '372', u: 'Mbit/s' } */
|
||||
bitsParts: function(bps) {
|
||||
bps = num(bps);
|
||||
if (bps == null || bps < 0) return { v: DASH, u: '' };
|
||||
var u = [ _('bit/s'), _('kbit/s'), _('Mbit/s'), _('Gbit/s'), _('Tbit/s') ], i = 0;
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
|
||||
return { v: i === 0 ? bps.toFixed(0) : sig3(bps), u: u[i] };
|
||||
},
|
||||
|
||||
bits: function(bps) {
|
||||
var p = this.bitsParts(bps);
|
||||
return p.u ? p.v + ' ' + p.u : p.v;
|
||||
},
|
||||
|
||||
/* compact axis label: 400M, 1.5G, 20k */
|
||||
bitsShort: function(bps) {
|
||||
bps = num(bps);
|
||||
if (bps == null || bps <= 0) return '0';
|
||||
var u = [ '', 'k', 'M', 'G', 'T' ], i = 0;
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
|
||||
return (bps >= 10 ? bps.toFixed(0) : String(+bps.toFixed(1))) + u[i];
|
||||
},
|
||||
|
||||
/* binary units for memory/storage */
|
||||
bytes: function(b) {
|
||||
b = num(b);
|
||||
if (b == null || b < 0) return DASH;
|
||||
var u = [ _('B'), _('KiB'), _('MiB'), _('GiB'), _('TiB'), _('PiB') ], i = 0;
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
|
||||
return (i === 0 ? b.toFixed(0) : b >= 100 ? b.toFixed(0) : b >= 10 ? b.toFixed(1) : b.toFixed(2)) + ' ' + u[i];
|
||||
},
|
||||
|
||||
/* PHY rate from iwinfo (kbit/s) -> '576 Mbit/s' */
|
||||
phyRate: function(kbit) {
|
||||
kbit = num(kbit);
|
||||
if (kbit == null || kbit <= 0) return DASH;
|
||||
return this.bits(kbit * 1000);
|
||||
},
|
||||
|
||||
pct: function(p, digits) {
|
||||
p = num(p);
|
||||
if (p == null) return DASH;
|
||||
return p.toFixed(digits || 0) + '%';
|
||||
},
|
||||
|
||||
/* seconds -> '5d 23h', '4h 12m', '3m 20s', '12s' */
|
||||
duration: function(s) {
|
||||
s = num(s);
|
||||
if (s == null || s < 0) return DASH;
|
||||
s = Math.floor(s);
|
||||
var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
|
||||
if (d) return d + 'd ' + h + 'h';
|
||||
if (h) return h + 'h ' + m + 'm';
|
||||
if (m) return m + 'm ' + (s % 60) + 's';
|
||||
return s + 's';
|
||||
},
|
||||
|
||||
/* milliseconds since -> 'just now', '12 s ago', '3 min ago' */
|
||||
ago: function(ms) {
|
||||
ms = num(ms);
|
||||
if (ms == null || ms < 0) return DASH;
|
||||
var s = Math.round(ms / 1000);
|
||||
if (s < 3) return _('just now');
|
||||
if (s < 60) return _('%d s ago').format(s);
|
||||
if (s < 3600) return _('%d min ago').format(Math.floor(s / 60));
|
||||
return _('%d h ago').format(Math.floor(s / 3600));
|
||||
},
|
||||
|
||||
dbm: function(v) {
|
||||
v = num(v);
|
||||
if (v == null || v === 0) return DASH;
|
||||
return (v < 0 ? '−' + Math.abs(v) : String(v)) + ' ' + _('dBm');
|
||||
},
|
||||
|
||||
/* load average as reported by system.info (fixed point, 65536 = 1.0) */
|
||||
load: function(v) {
|
||||
v = num(v);
|
||||
return v == null ? DASH : (v / 65536).toFixed(2);
|
||||
},
|
||||
|
||||
/* upper-case, colon separated; anything else is returned unchanged */
|
||||
mac: function(mac) {
|
||||
var s = String(mac == null ? '' : mac).trim();
|
||||
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(s) ? s.replace(/-/g, ':').toUpperCase() : s;
|
||||
},
|
||||
|
||||
/* last three octets: '…00:53:C1' (the distinguishing part of a MAC) */
|
||||
macTail: function(mac) {
|
||||
var m = this.mac(mac);
|
||||
return /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(m) ? '…' + m.slice(9) : m;
|
||||
},
|
||||
|
||||
/* Client- or radio-supplied text for display: invisible/reordering
|
||||
characters become U+FFFD and long values are cut (by code point) with
|
||||
an ellipsis. The result is still only ever used as a text node. */
|
||||
safeText: function(s, maxLen) {
|
||||
if (s == null) return '';
|
||||
var chars = Array.from(String(s).replace(UNSAFE_TEXT, '\ufffd'));
|
||||
maxLen = maxLen > 1 ? maxLen : 64;
|
||||
return chars.length > maxLen ? chars.slice(0, maxLen - 1).join('') + '…' : chars.join('');
|
||||
},
|
||||
|
||||
/* frequency MHz -> '5975 MHz' / '5.975 GHz' is less readable; keep MHz */
|
||||
mhz: function(v) {
|
||||
v = num(v);
|
||||
return v ? _('%s MHz').format(v) : DASH;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require vantage.fmt as fmt';
|
||||
'require vantage.wifi as wifi';
|
||||
|
||||
/* Explainable judgements over the model: health checks, per-client
|
||||
experience, top talkers, session presence and radio insights. Every
|
||||
verdict carries a one-line reason and, where something can be done
|
||||
about it, the LuCI page that does it. Pure functions. */
|
||||
|
||||
var PAGES = {
|
||||
network: [ 'admin', 'network', 'network' ],
|
||||
wireless: [ 'admin', 'network', 'wireless' ],
|
||||
processes: [ 'admin', 'status', 'processes' ],
|
||||
mounts: [ 'admin', 'system', 'mounts' ],
|
||||
realtime: [ 'admin', 'status', 'realtime' ]
|
||||
};
|
||||
|
||||
var WEAK_DBM = -75;
|
||||
|
||||
function num(v) { v = +v; return isFinite(v) ? v : 0; }
|
||||
function finite(v) { return typeof v === 'number' && isFinite(v); }
|
||||
function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); }
|
||||
function speedShort(mbps) { return mbps >= 1000 ? (mbps / 1000) + 'G' : mbps + 'M'; }
|
||||
function speedLong(mbps) { return mbps >= 1000 ? _('%s Gbit/s').format(mbps / 1000) : _('%s Mbit/s').format(mbps); }
|
||||
function rank(level) { return { err: 3, warn: 2, info: 1, ok: 0 }[level] || 0; }
|
||||
|
||||
return baseclass.extend({
|
||||
PAGES: PAGES,
|
||||
WEAK_DBM: WEAK_DBM,
|
||||
|
||||
/* ------------------------------------------------------------ health */
|
||||
|
||||
/* model + { cpu: { pct, cores } | null, uplinkRate: { rx, tx } | null }
|
||||
-> [ { id, level, title, value, reason, detail, page } ]
|
||||
value and reason are short enough for a health chip; detail is the
|
||||
full sentence (tooltip / accessible description). */
|
||||
health: function(model, live) {
|
||||
model = model || {};
|
||||
live = live || {};
|
||||
var out = [], dev = model.device || {}, up = model.uplink;
|
||||
|
||||
/* uplink: live traffic is the value, the negotiated link the context */
|
||||
if (!up) out.push({ id: 'uplink', level: 'err', title: _('Uplink'), value: _('Down'), reason: _('No interface with a route'),
|
||||
detail: _('No network interface with a route is up'), page: PAGES.network });
|
||||
else if (up.carrier === false) out.push({ id: 'uplink', level: 'err', title: _('Uplink'), value: _('No link'), reason: _('%s: no carrier').format(up.dev),
|
||||
detail: _('%s has no carrier: cable unplugged or switch port down').format(up.dev), page: PAGES.network });
|
||||
else if (up.speed && up.speed.duplex === 'half') out.push({ id: 'uplink', level: 'warn', title: _('Uplink'), value: _('Half duplex'), reason: _('%s · check cable').format(up.dev),
|
||||
detail: _('%s negotiated half duplex; check the cable and switch port').format(up.dev), page: PAGES.network });
|
||||
else if (up.speed && up.speed.mbps < 1000) out.push({ id: 'uplink', level: 'warn', title: _('Uplink'), value: _('%s Mbit/s').format(up.speed.mbps), reason: _('%s · slow link').format(up.dev),
|
||||
detail: _('%s negotiated only %d Mbit/s; a cable pair or port may be faulty').format(up.dev, up.speed.mbps), page: PAGES.network });
|
||||
else {
|
||||
var rate = live.uplinkRate && finite(live.uplinkRate.rx) && finite(live.uplinkRate.tx) ? live.uplinkRate : null;
|
||||
/* no-break spaces keep '↑ 2.9 Mbit/s' and '2.5G link' whole when the chip wraps */
|
||||
var link = up.speed ? _('%s link').format(speedShort(up.speed.mbps)).replace(/ /g, '\u00a0') : null;
|
||||
out.push({ id: 'uplink', level: 'ok', title: _('Uplink'), value: rate ? '↓ ' + fmt.bits(rate.rx) : _('Up'),
|
||||
reason: [ rate ? ('↑ ' + fmt.bits(rate.tx)).replace(/ /g, '\u00a0') : null, up.dev, link ].filter(Boolean).join(' · '),
|
||||
detail: (up.speed ? _('%s up at %s').format(up.dev, speedLong(up.speed.mbps)) : _('%s up').format(up.dev)) +
|
||||
(up.gateway ? ', ' + _('gateway %s').format(up.gateway) : '') +
|
||||
(rate ? '; ' + _('now %s down, %s up').format(fmt.bits(rate.rx), fmt.bits(rate.tx)) : ''),
|
||||
page: PAGES.network });
|
||||
}
|
||||
|
||||
/* CPU */
|
||||
var cpu = live.cpu && finite(live.cpu.pct) ? live.cpu.pct : null, cores = (live.cpu && live.cpu.cores && live.cpu.cores.length) || 1;
|
||||
var load = (dev.load || [])[0];
|
||||
if (cpu == null) out.push({ id: 'cpu', level: 'info', title: _('CPU'), value: fmt.DASH, reason: _('Measuring…'), detail: _('Measuring over the next poll'), page: PAGES.processes });
|
||||
else out.push({ id: 'cpu', level: cpu >= 90 ? 'err' : cpu >= 70 ? 'warn' : 'ok', title: _('CPU'), value: fmt.pct(cpu),
|
||||
reason: cpu >= 70 ? _('Busy · load %s').format(fmt.load(load)) : _('Load %s · %d cores').format(fmt.load(load), cores),
|
||||
detail: cpu >= 70 ? _('Busy; load %s on %d cores; see the busiest processes').format(fmt.load(load), cores) : _('Load %s on %d cores').format(fmt.load(load), cores),
|
||||
page: PAGES.processes });
|
||||
|
||||
/* memory, framed as available (as in the System tile) */
|
||||
var mem = dev.memory;
|
||||
if (mem) {
|
||||
var avail = finite(mem.availPct) ? mem.availPct : 100 - num(mem.usedPct);
|
||||
out.push({ id: 'memory', level: mem.usedPct >= 90 ? 'err' : mem.usedPct >= 80 ? 'warn' : 'ok', title: _('Memory'), value: _('%s available').format(fmt.pct(avail)),
|
||||
reason: _('%s of %s').format(fmt.bytes(mem.available), fmt.bytes(mem.total)),
|
||||
detail: _('%s of %s available (%s in use)').format(fmt.bytes(mem.available), fmt.bytes(mem.total), fmt.pct(mem.usedPct)), page: PAGES.processes });
|
||||
}
|
||||
|
||||
/* radios; an SSID without clients is normal, not a fault */
|
||||
var radios = model.radios || [], nSsid = (model.ssids || []).length;
|
||||
var off = radios.filter(function(r) { return r.disabled; }), down = radios.filter(function(r) { return !r.disabled && !r.up; });
|
||||
var idle = (model.ssids || []).filter(function(s) { return s.up && !s.clients; });
|
||||
if (!radios.length) out.push({ id: 'radios', level: 'info', title: _('Wi-Fi'), value: _('None'), reason: _('No radios reported'), detail: _('This device reports no radios'), page: PAGES.wireless });
|
||||
else if (down.length) out.push({ id: 'radios', level: 'err', title: _('Wi-Fi'), value: _('%d down').format(down.length), reason: _('%s is not up').format(down[0].bandLabel),
|
||||
detail: _('%s (%s) is enabled but not up').format(down[0].id, down[0].bandLabel), page: PAGES.wireless });
|
||||
else if (off.length) out.push({ id: 'radios', level: 'warn', title: _('Wi-Fi'), value: _('%d off').format(off.length), reason: _('%s is disabled').format(off[0].bandLabel),
|
||||
detail: _('%s (%s) is disabled').format(off[0].id, off[0].bandLabel), page: PAGES.wireless });
|
||||
else out.push({ id: 'radios', level: 'ok', title: _('Wi-Fi'), value: _('%d up').format(radios.length),
|
||||
reason: idle.length ? _('%d SSIDs · %d idle').format(nSsid, idle.length) : _('%d SSIDs on air').format(nSsid),
|
||||
detail: idle.length ? _('%d SSIDs broadcasting; %d without clients, which is fine').format(nSsid, idle.length) : _('%d SSIDs broadcasting').format(nSsid),
|
||||
page: PAGES.wireless });
|
||||
|
||||
/* signal */
|
||||
var clients = model.clients || [];
|
||||
var weak = clients.filter(function(c) { return c.signal != null && c.signal < WEAK_DBM; }).sort(function(a, b) { return a.signal - b.signal; });
|
||||
if (!clients.length) out.push({ id: 'signal', level: 'info', title: _('Signal'), value: _('No clients'), reason: _('Nobody associated'), detail: _('No stations are associated right now'), page: PAGES.wireless });
|
||||
else if (weak.length) out.push({ id: 'signal', level: 'warn', title: _('Signal'), value: _('%d weak').format(weak.length),
|
||||
reason: _('%s · %s').format(weak[0].name, fmt.dbm(weak[0].signal)),
|
||||
detail: _('%s at %s; below %s roaming and throughput suffer').format(weak[0].name, fmt.dbm(weak[0].signal), fmt.dbm(WEAK_DBM)), page: PAGES.wireless, client: weak[0].mac });
|
||||
else out.push({ id: 'signal', level: 'ok', title: _('Signal'), value: _('All good'), reason: _('All above %s').format(fmt.dbm(WEAK_DBM)),
|
||||
detail: _('All %d clients above %s').format(clients.length, fmt.dbm(WEAK_DBM)), page: PAGES.wireless });
|
||||
|
||||
/* storage */
|
||||
var root = (dev.storage || [])[0];
|
||||
if (root) out.push({ id: 'storage', level: root.usedPct >= 95 ? 'err' : root.usedPct >= 85 ? 'warn' : 'ok', title: _('Storage'), value: _('%s used').format(fmt.pct(root.usedPct)),
|
||||
reason: _('%s free').format(fmt.bytes(root.free)), detail: _('%s free on the overlay (%s used)').format(fmt.bytes(root.free), fmt.pct(root.usedPct)), page: PAGES.mounts });
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
/* -> { level, count (warn+err), headline } */
|
||||
summary: function(checks) {
|
||||
var worst = 'ok', n = 0;
|
||||
(checks || []).forEach(function(c) {
|
||||
if (rank(c.level) > rank(worst)) worst = c.level;
|
||||
if (c.level === 'warn' || c.level === 'err') n++;
|
||||
});
|
||||
var headline = n === 0 ? _('Everything looks healthy') : n === 1 ? _('1 thing needs attention') : _('%d things need attention').format(n);
|
||||
return { level: worst === 'info' ? 'ok' : worst, count: n, headline: headline };
|
||||
},
|
||||
|
||||
/* -------------------------------------------------------- experience */
|
||||
|
||||
/* Per-client experience 0..100 from signal, link rate, delivery
|
||||
failures and the generation gap to the radio. The explanation is
|
||||
the factor that cost the most. -> { score, grade, level, reason, short, factors } */
|
||||
experience: function(c) {
|
||||
c = c || {};
|
||||
var factors = [], s = c.signal;
|
||||
if (s == null) factors.push({ cost: 0, text: _('Signal not reported'), short: _('Signal not reported') });
|
||||
else if (s < -80) factors.push({ cost: 70, text: _('Very weak signal %s; expect drops').format(fmt.dbm(s)), short: _('Very weak signal; expect drops') });
|
||||
else if (s < WEAK_DBM) factors.push({ cost: 55, text: _('Weak signal %s; may roam poorly').format(fmt.dbm(s)), short: _('Weak signal') });
|
||||
else if (s < -67) factors.push({ cost: 30, text: _('Fair signal %s; lower rates at range').format(fmt.dbm(s)), short: _('Fair signal') });
|
||||
else if (s < -60) factors.push({ cost: 10, text: _('Good signal %s').format(fmt.dbm(s)), short: _('Good signal') });
|
||||
|
||||
var best = Math.max(c.rx && c.rx.rate || 0, c.tx && c.tx.rate || 0);
|
||||
var active = c.inactive == null || c.inactive < 10000;
|
||||
if (best > 0 && best < 20000 && active && s != null && s >= -70)
|
||||
factors.push({ cost: 20, text: _('Low link rate %s despite good signal; interference or an old device').format(fmt.phyRate(best)), short: _('Low link rate for its signal') });
|
||||
|
||||
var sent = num(c.txPackets) + num(c.txFailed);
|
||||
if (sent >= 200) {
|
||||
var fail = num(c.txFailed) / sent * 100;
|
||||
if (fail >= 10) factors.push({ cost: 25, text: _('%s of frames to the device fail; interference likely').format(fmt.pct(fail)), short: _('%s of frames fail').format(fmt.pct(fail)) });
|
||||
else if (fail >= 4) factors.push({ cost: 10, text: _('%s of frames to the device fail').format(fmt.pct(fail)), short: _('%s of frames fail').format(fmt.pct(fail)) });
|
||||
}
|
||||
|
||||
if (c.legacyOnRadio)
|
||||
factors.push({ cost: 5, text: _('%s device on a newer radio; uses more airtime per byte').format(c.gen === 'legacy' ? _('Legacy') : c.gen),
|
||||
short: _('Older %s device').format(c.gen === 'legacy' ? _('legacy') : c.gen) });
|
||||
|
||||
var cost = factors.reduce(function(a, f) { return a + f.cost; }, 0);
|
||||
var score = Math.max(0, Math.min(100, 100 - cost));
|
||||
var grade = score >= 85 ? _('Excellent') : score >= 65 ? _('Good') : score >= 40 ? _('Fair') : _('Poor');
|
||||
var level = score >= 65 ? 'ok' : score >= 40 ? 'warn' : 'err';
|
||||
var worst = factors.slice().sort(function(a, b) { return b.cost - a.cost; })[0];
|
||||
var reason = (worst && worst.cost > 0) ? worst.text
|
||||
: (s != null ? _('Strong signal %s, %s link').format(fmt.dbm(s), fmt.phyRate(best)) : _('No problems detected'));
|
||||
var short = (worst && worst.cost > 0) ? worst.short
|
||||
: (s != null ? (best > 0 ? _('Strong signal, %s').format(fmt.phyRate(best)) : _('Strong signal')) : _('No problems detected'));
|
||||
return { score: score, grade: grade, level: level, reason: reason, short: short, factors: factors };
|
||||
},
|
||||
|
||||
/* ------------------------------------------------------ top talkers */
|
||||
|
||||
/* clients + rates { mac: { rx, tx } } (AP view: tx = to the client)
|
||||
-> top n [ { mac, name, down, up, total, share } ] with total > 0 */
|
||||
topTalkers: function(clients, rates, n) {
|
||||
var list = [], sum = 0;
|
||||
(clients || []).forEach(function(c) {
|
||||
var r = own(rates, c.mac) ? rates[c.mac] : null;
|
||||
if (!r) return;
|
||||
var down = num(r.tx), up = num(r.rx), total = down + up;
|
||||
if (!(total > 0)) return;
|
||||
sum += total;
|
||||
list.push({ mac: c.mac, name: c.name, down: down, up: up, total: total });
|
||||
});
|
||||
list.sort(function(a, b) { return b.total - a.total || (a.mac < b.mac ? -1 : 1); });
|
||||
list.forEach(function(t) { t.share = sum > 0 ? t.total / sum : 0; });
|
||||
return list.slice(0, n > 0 ? n : 5);
|
||||
},
|
||||
|
||||
/* --------------------------------------------------------- presence */
|
||||
|
||||
/* Session presence. state: { baseline: [macs] | null, present: { mac: client },
|
||||
gone: { mac: { client, at } }, firstSeen: { mac: ms } }.
|
||||
The first call fixes the baseline (what was there when the page
|
||||
opened); later arrivals are "new". Stations that leave are kept as
|
||||
gone for keepMs. -> { state, isNew: { mac: true }, gone: [ { client, at } ] } */
|
||||
presence: function(state, clients, now, keepMs) {
|
||||
state = state || {};
|
||||
var baseline = Array.isArray(state.baseline) ? state.baseline : null;
|
||||
var present = Object.create(null), gone = Object.create(null), firstSeen = Object.create(null), isNew = Object.create(null);
|
||||
var prevPresent = state.present || {}, prevGone = state.gone || {}, prevFirst = state.firstSeen || {};
|
||||
|
||||
(clients || []).forEach(function(c) {
|
||||
present[c.mac] = c;
|
||||
firstSeen[c.mac] = own(prevFirst, c.mac) ? prevFirst[c.mac] : now;
|
||||
if (baseline && baseline.indexOf(c.mac) < 0) isNew[c.mac] = true;
|
||||
});
|
||||
Object.keys(prevGone).forEach(function(mac) {
|
||||
var g = prevGone[mac];
|
||||
if (!own(present, mac) && g && now - g.at < keepMs) { gone[mac] = g; firstSeen[mac] = prevFirst[mac]; }
|
||||
});
|
||||
Object.keys(prevPresent).forEach(function(mac) {
|
||||
if (!own(present, mac)) { gone[mac] = { client: prevPresent[mac], at: now }; firstSeen[mac] = prevFirst[mac]; }
|
||||
});
|
||||
|
||||
var goneList = Object.keys(gone).map(function(k) { return gone[k]; }).sort(function(a, b) { return b.at - a.at; });
|
||||
return {
|
||||
state: { baseline: baseline || Object.keys(present), present: present, gone: gone, firstSeen: firstSeen },
|
||||
isNew: isNew,
|
||||
gone: goneList
|
||||
};
|
||||
},
|
||||
|
||||
/* ---------------------------------------------------------- radios */
|
||||
|
||||
/* radios + per-radio rates { id: { rx, tx } } -> { id: { busiest, share, notes: [ { level, text } ] } } */
|
||||
radioInsights: function(radios, rates) {
|
||||
var out = {}, sum = 0, top = null, topV = 0;
|
||||
(radios || []).forEach(function(r) {
|
||||
var v = own(rates, r.id) ? num(rates[r.id].rx) + num(rates[r.id].tx) : 0;
|
||||
sum += v;
|
||||
if (v > topV) { topV = v; top = r.id; }
|
||||
});
|
||||
(radios || []).forEach(function(r) {
|
||||
var v = own(rates, r.id) ? num(rates[r.id].rx) + num(rates[r.id].tx) : 0;
|
||||
var notes = [];
|
||||
if (r.disabled) notes.push({ level: 'warn', text: _('Radio is disabled') });
|
||||
else if (!r.up) notes.push({ level: 'err', text: _('Radio is enabled but not up') });
|
||||
if (top === r.id && sum > 0 && (radios.length > 1)) notes.push({ level: 'info', text: _('Busiest radio: %s of Wi-Fi traffic').format(fmt.pct(v / sum * 100)) });
|
||||
if (r.legacy) notes.push({ level: 'info', text: r.legacy === 1 ? _('1 older device (Wi-Fi 4 or earlier) slows airtime') : _('%d older devices (Wi-Fi 4 or earlier) slow airtime').format(r.legacy) });
|
||||
if (r.noise == null && r.noiseRaw != null) notes.push({ level: 'muted', text: _('Noise floor not reported by the driver') });
|
||||
if (r.up && !r.clients) notes.push({ level: 'muted', text: _('No clients; idle radios are fine') });
|
||||
out[r.id] = { busiest: top === r.id && sum > 0, share: sum > 0 ? v / sum : 0, notes: notes };
|
||||
});
|
||||
return out;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require vantage.fmt as fmt';
|
||||
'require vantage.wifi as wifi';
|
||||
'require vantage.names as names';
|
||||
|
||||
/* Turns raw ubus replies into one normalised model of the device, its
|
||||
uplink, radios, SSIDs and stations, plus the counter/rate arithmetic.
|
||||
Pure functions: the view owns all state and passes it in. */
|
||||
|
||||
/* interface names that are wireless even when netifd reports no devtype
|
||||
(it does not on ipq53xx/ath12k: phy2g-ap0, phy6g-ap0, ...) */
|
||||
var WIRELESS_NAME = /^(wlan\d|phy[0-9a-z]*-|ath\d|wl\d|ra\d|rai\d|rax\d|mld\d)/;
|
||||
|
||||
/* above any real link: a rate this high means a bad sample, not traffic */
|
||||
var MAX_BPS = 400e9;
|
||||
|
||||
function num(v) { v = +v; return isFinite(v) ? v : 0; }
|
||||
function finite(v) { return typeof v === 'number' && isFinite(v); }
|
||||
function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); }
|
||||
function obj(v) { return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {}; }
|
||||
function arr(v) { return Array.isArray(v) ? v : []; }
|
||||
|
||||
return baseclass.extend({
|
||||
/* ------------------------------------------------------------- CPU */
|
||||
|
||||
/* /proc/stat -> { all: { idle, total }, cores: [ { idle, total } ] } */
|
||||
parseStat: function(text) {
|
||||
var out = { all: null, cores: [] };
|
||||
String(text || '').split('\n').forEach(function(line) {
|
||||
var m = line.match(/^cpu(\d*)\s+([\d\s]+)$/);
|
||||
if (!m) return;
|
||||
var f = m[2].trim().split(/\s+/).map(num);
|
||||
/* user nice system idle iowait irq softirq steal */
|
||||
var idle = f[3] + (f[4] || 0), total = 0;
|
||||
for (var i = 0; i < Math.min(f.length, 8); i++) total += f[i];
|
||||
if (m[1] === '') out.all = { idle: idle, total: total };
|
||||
else out.cores[+m[1]] = { idle: idle, total: total };
|
||||
});
|
||||
out.cores = out.cores.filter(Boolean);
|
||||
return out.all ? out : null;
|
||||
},
|
||||
|
||||
cpuPercent: function(prev, cur) {
|
||||
if (!prev || !cur) return null;
|
||||
var dt = cur.total - prev.total, di = cur.idle - prev.idle;
|
||||
if (!(dt > 0)) return null;
|
||||
return Math.max(0, Math.min(100, (1 - di / dt) * 100));
|
||||
},
|
||||
|
||||
/* { pct, cores: [ pct, ... ] } between two parseStat() results */
|
||||
cpuUsage: function(prev, cur) {
|
||||
if (!prev || !cur) return null;
|
||||
var self = this, pct = this.cpuPercent(prev.all, cur.all);
|
||||
if (pct == null) return null;
|
||||
return { pct: pct, cores: cur.cores.map(function(c, i) { return self.cpuPercent(prev.cores[i], c); }) };
|
||||
},
|
||||
|
||||
/* ---------------------------------------------------------- memory */
|
||||
|
||||
/* One bar, three segments that add up to total:
|
||||
In use total - available
|
||||
Cache (reclaimable) available - free (page cache, buffers)
|
||||
Free free
|
||||
'available' is the kernel's estimate (MemAvailable); without it
|
||||
free + buffered + cached stands in. */
|
||||
memory: function(m) {
|
||||
m = obj(m);
|
||||
var total = num(m.total);
|
||||
if (!(total > 0)) return null;
|
||||
var free = Math.min(Math.max(num(m.free), 0), total);
|
||||
var avail = finite(m.available) ? m.available : free + num(m.buffered) + num(m.cached);
|
||||
avail = Math.min(Math.max(avail, free), total);
|
||||
var used = total - avail, cache = avail - free;
|
||||
return {
|
||||
total: total, available: avail, used: used, cache: cache, free: free,
|
||||
availPct: avail / total * 100, usedPct: used / total * 100,
|
||||
segments: [
|
||||
{ key: 'used', label: _('In use'), bytes: used, pct: used / total * 100 },
|
||||
{ key: 'cache', label: _('Cache (reclaimable)'), bytes: cache, pct: cache / total * 100 },
|
||||
{ key: 'free', label: _('Free'), bytes: free, pct: free / total * 100 }
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
/* system.info swap -> { total, used, free, usedPct } or null when none */
|
||||
swap: function(s) {
|
||||
s = obj(s);
|
||||
var total = num(s.total), free = Math.min(Math.max(num(s.free), 0), total);
|
||||
if (!(total > 0)) return null;
|
||||
return { total: total, free: free, used: total - free, usedPct: (total - free) / total * 100 };
|
||||
},
|
||||
|
||||
/* system.info root/tmp (KiB) -> bytes { total, used, free, usedPct } */
|
||||
mount: function(s, label) {
|
||||
s = obj(s);
|
||||
var total = num(s.total) * 1024;
|
||||
if (!(total > 0)) return null;
|
||||
var used = Math.min(Math.max(num(s.used) * 1024, 0), total);
|
||||
var free = finite(s.avail) ? Math.min(num(s.avail) * 1024, total - used) : total - used;
|
||||
return { label: label, total: total, used: used, free: free, usedPct: used / total * 100 };
|
||||
},
|
||||
|
||||
/* ---------------------------------------------------------- counters */
|
||||
|
||||
/* Rate between two byte-counter samples { at: ms, rx, tx }.
|
||||
'first': no previous sample; 'skip': under 1 s apart (keep prev as
|
||||
the base); 'reset': a counter went backwards or the rate is not
|
||||
physical (restart from cur); 'ok': rx/tx in bit/s. */
|
||||
counterRate: function(prev, cur) {
|
||||
if (!prev || !cur || !finite(prev.at) || !finite(cur.at)) return { state: 'first' };
|
||||
var dt = (cur.at - prev.at) / 1000;
|
||||
if (!(dt >= 1)) return { state: 'skip' };
|
||||
var rx = (num(cur.rx) - num(prev.rx)) * 8 / dt, tx = (num(cur.tx) - num(prev.tx)) * 8 / dt;
|
||||
if (!(rx >= 0 && tx >= 0 && rx <= MAX_BPS && tx <= MAX_BPS)) return { state: 'reset' };
|
||||
return { state: 'ok', rx: rx, tx: tx, dt: dt };
|
||||
},
|
||||
|
||||
/* Capture time for one poll's counters. The browser clock is precise
|
||||
but only says when the reply arrived; system.info localtime (whole
|
||||
seconds) says which sample the device served. Same localtime as
|
||||
last poll: the same snapshot again (-> dt 0, the rate is skipped).
|
||||
The two clocks disagree by more than 2 s (a delayed reply, a
|
||||
replayed or skipped sample): trust the device's spacing.
|
||||
prev/next: { at: ms, local: s } */
|
||||
sampleClock: function(prev, now, local) {
|
||||
if (!finite(local)) return { at: now, local: null };
|
||||
if (!prev || !finite(prev.at) || !finite(prev.local)) return { at: now, local: local };
|
||||
var dLocal = (local - prev.local) * 1000, dClient = now - prev.at;
|
||||
if (dLocal === 0) return { at: prev.at, local: local };
|
||||
if (dLocal < 0 || Math.abs(dLocal - dClient) > 2000) return { at: prev.at + Math.max(dLocal, 0) || now, local: local };
|
||||
return { at: now, local: local };
|
||||
},
|
||||
|
||||
/* Rates for a set of counters { key: { rx, tx } } captured at `at`.
|
||||
Returns { next, rates }; next holds only keys present now, so the
|
||||
state cannot grow with every station that ever associated. */
|
||||
rates: function(prevMap, counters, at) {
|
||||
var next = Object.create(null), rates = Object.create(null), self = this;
|
||||
Object.keys(obj(counters)).forEach(function(k) {
|
||||
var c = counters[k];
|
||||
if (!c) return;
|
||||
var p = own(prevMap, k) ? prevMap[k] : null;
|
||||
var cur = { at: at, rx: num(c.rx), tx: num(c.tx) };
|
||||
var r = self.counterRate(p, cur);
|
||||
if (r.state === 'ok') rates[k] = { rx: r.rx, tx: r.tx };
|
||||
next[k] = r.state === 'skip' ? p : cur;
|
||||
});
|
||||
return { next: next, rates: rates };
|
||||
},
|
||||
|
||||
/* ------------------------------------------------------------ uplink */
|
||||
|
||||
isWireless: function(name, dev, known) {
|
||||
if (known && known.indexOf(name) >= 0) return true;
|
||||
if (dev && (dev.devtype === 'wlan' || dev.wireless === true)) return true;
|
||||
return WIRELESS_NAME.test(String(name || ''));
|
||||
},
|
||||
|
||||
parseSpeed: function(s) {
|
||||
var m = String(s == null ? '' : s).match(/^(\d+)([FH])?/);
|
||||
if (!m || +m[1] <= 0) return null;
|
||||
return { mbps: +m[1], duplex: m[2] === 'H' ? 'half' : (m[2] === 'F' ? 'full' : null) };
|
||||
},
|
||||
|
||||
speedLabel: function(sp) {
|
||||
if (!sp) return fmt.DASH;
|
||||
var v = sp.mbps >= 1000 ? _('%s Gbit/s').format(sp.mbps / 1000) : _('%s Mbit/s').format(sp.mbps);
|
||||
return v + (sp.duplex === 'full' ? ' · ' + _('full duplex') : sp.duplex === 'half' ? ' · ' + _('half duplex') : '');
|
||||
},
|
||||
|
||||
/* Pick the interface carrying traffic in/out of this device, and the
|
||||
physical device its counters live on. Routers: wan/wan6/*wan*.
|
||||
Access points: the interface with the default route, else the first
|
||||
one that is up. A bridge is followed to its wired member (wireless
|
||||
members are skipped; netifd 'ethernet' members preferred); DSA with
|
||||
hardware offload uses the conduit's counters. */
|
||||
resolveUplink: function(ifaces, devs, wirelessNames) {
|
||||
var self = this;
|
||||
ifaces = arr(ifaces);
|
||||
devs = obj(devs);
|
||||
|
||||
var up = ifaces.filter(function(i) {
|
||||
return i && i.up && i.interface !== 'loopback' && (i.l3_device || i.device);
|
||||
});
|
||||
function named(re) { return up.filter(function(i) { return re.test(i.interface); })[0]; }
|
||||
function defRoute(i) {
|
||||
return arr(i.route).filter(function(r) {
|
||||
return r && +r.mask === 0 && (r.target === '0.0.0.0' || r.target === '::');
|
||||
})[0] || null;
|
||||
}
|
||||
|
||||
var wan = named(/^wan$/) || named(/^wan6$/) || named(/wan/i);
|
||||
var pick = wan || up.filter(defRoute)[0] || up[0];
|
||||
if (!pick) return null;
|
||||
|
||||
var l3 = pick.l3_device || pick.device, dev = l3, d = devs[dev];
|
||||
if (d && (d.type === 'bridge' || d.devtype === 'bridge') && Array.isArray(d['bridge-members'])) {
|
||||
var members = d['bridge-members'];
|
||||
var wired = members.filter(function(m) { return !self.isWireless(m, devs[m], wirelessNames); });
|
||||
var eth = wired.filter(function(m) { return devs[m] && devs[m].devtype === 'ethernet'; });
|
||||
var next = eth[0] || wired[0] || members[0];
|
||||
if (next) { dev = next; d = devs[dev] || null; }
|
||||
}
|
||||
|
||||
var statsDev = dev;
|
||||
if (d && d.devtype === 'dsa' && d['hw-tc-offload'] && d.conduit && devs[d.conduit])
|
||||
statsDev = d.conduit;
|
||||
|
||||
var route = defRoute(pick);
|
||||
return {
|
||||
iface: pick.interface,
|
||||
l3dev: l3,
|
||||
dev: dev,
|
||||
statsDev: statsDev,
|
||||
isWan: !!wan,
|
||||
proto: pick.proto || null,
|
||||
uptime: finite(pick.uptime) ? pick.uptime : null,
|
||||
up: !!pick.up,
|
||||
carrier: d ? d.carrier !== false : null,
|
||||
speed: d ? this.parseSpeed(d.speed) : null,
|
||||
ipv4: arr(pick['ipv4-address']).map(function(a) { return a && a.address ? a.address + '/' + a.mask : null; }).filter(Boolean),
|
||||
ipv6: arr(pick['ipv6-address']).map(function(a) { return a && a.address ? a.address + '/' + a.mask : null; }).filter(Boolean),
|
||||
gateway: route && route.nexthop && route.nexthop !== '0.0.0.0' ? route.nexthop : null,
|
||||
dns: arr(pick['dns-server']).filter(function(s) { return typeof s === 'string'; }),
|
||||
stats: d && d.statistics ? d.statistics : null
|
||||
};
|
||||
},
|
||||
|
||||
/* The device tree model is sometimes the SoC reference design
|
||||
("Qualcomm Technologies, Inc. IPQ5332/RDP442/..."); board_name
|
||||
("zyxel,nwa50be") names the actual product then. */
|
||||
deviceModel: function(board) {
|
||||
board = obj(board);
|
||||
var model = String(board.model || ''), m = String(board.board_name || '').match(/^([^,]+),(.+)$/);
|
||||
if (m && (!model || /Qualcomm Technologies|\bRDP\d|reference board|\bEVB\b|\bRFB\b/i.test(model)))
|
||||
return m[1].charAt(0).toUpperCase() + m[1].slice(1) + ' ' + m[2].toUpperCase();
|
||||
return model || board.board_name || null;
|
||||
},
|
||||
|
||||
/* ------------------------------------------------ address filtering */
|
||||
|
||||
parseIPv4: function(s) {
|
||||
var m = typeof s === 'string' ? s.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) : null;
|
||||
if (!m) return null;
|
||||
var out = [];
|
||||
for (var i = 1; i <= 4; i++) {
|
||||
if (m[i].length > 1 && m[i].charAt(0) === '0') return null;
|
||||
if (+m[i] > 255) return null;
|
||||
out.push(+m[i]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
ipv4InSubnet: function(ip, net, mask) {
|
||||
var a = this.parseIPv4(ip), b = this.parseIPv4(net);
|
||||
mask = +mask;
|
||||
if (!a || !b || !(mask >= 0 && mask <= 32)) return false;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var bits = Math.max(0, Math.min(8, mask - i * 8));
|
||||
if (!bits) break;
|
||||
var m = (0xff << (8 - bits)) & 0xff;
|
||||
if ((a[i] & m) !== (b[i] & m)) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/* host-hint IPv4 addresses inside one of the given subnets
|
||||
([ 'a.b.c.d/nn', ... ]); hints are keyed by MAC only, so a hint may
|
||||
name any address. IPv6 link-local and global addresses are kept. */
|
||||
hintAddrs: function(hint, subnets) {
|
||||
var self = this, out = [];
|
||||
hint = obj(hint);
|
||||
arr(hint.ipaddrs).forEach(function(ip) {
|
||||
if (typeof ip !== 'string' || out.indexOf(ip) >= 0) return;
|
||||
if (!subnets.length || subnets.some(function(s) { var p = s.split('/'); return +p[1] >= 8 && self.ipv4InSubnet(ip, p[0], p[1]); }))
|
||||
out.push(ip);
|
||||
});
|
||||
arr(hint.ip6addrs).forEach(function(ip) {
|
||||
if (typeof ip === 'string' && /^[0-9a-f:]+$/i.test(ip) && ip.length <= 45 && out.indexOf(ip) < 0) out.push(ip);
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
/* ------------------------------------------------------------ model */
|
||||
|
||||
/* raw: { board, info, ifaces, devs, wifi, iwinfo: { ifname: info },
|
||||
assoc: { ifname: [ station ] }, hapd: { ifname: { clients } },
|
||||
hapdStatus: { ifname: status }, hints, aliases, rdns, mdns,
|
||||
vendor: fn(mac) -> name|null } */
|
||||
build: function(raw) {
|
||||
raw = raw || {};
|
||||
var self = this, board = obj(raw.board), info = obj(raw.info), devs = obj(raw.devs), hints = obj(raw.hints);
|
||||
var wifiDevs = obj(raw.wifi), iw = obj(raw.iwinfo), assoc = obj(raw.assoc), hapd = obj(raw.hapd), hstat = obj(raw.hapdStatus);
|
||||
|
||||
var radios = [], ssids = [], clients = [], wlNames = [];
|
||||
|
||||
Object.keys(wifiDevs).sort().forEach(function(rname) {
|
||||
var r = obj(wifiDevs[rname]), cfg = obj(r.config), rIw = obj(r.iwinfo);
|
||||
var ifs = arr(r.interfaces).filter(function(i) { return i && typeof i === 'object'; });
|
||||
var firstIf = ifs.filter(function(i) { return i.ifname && own(iw, i.ifname); })[0];
|
||||
var info0 = firstIf ? obj(iw[firstIf.ifname]) : {};
|
||||
var band = wifi.bandKey(cfg.band, info0.frequency || rIw.frequency);
|
||||
var ht = wifi.htmode(info0.htmode || cfg.htmode);
|
||||
var chan = num(info0.channel || rIw.channel || cfg.channel) || null;
|
||||
var radio = {
|
||||
id: rname,
|
||||
band: band,
|
||||
bandLabel: wifi.bandLabel(band),
|
||||
up: r.up === true,
|
||||
disabled: r.disabled === true || cfg.disabled === true || cfg.disabled === '1',
|
||||
pending: r.pending === true,
|
||||
channel: chan,
|
||||
auto: cfg.channel === 'auto',
|
||||
centre: num(info0.center_chan1) || null,
|
||||
freq: num(info0.frequency || rIw.frequency) || null,
|
||||
width: ht.width,
|
||||
std: ht.std,
|
||||
gen: wifi.genName(ht.std, band),
|
||||
htmode: info0.htmode || cfg.htmode || null,
|
||||
txpower: finite(info0.txpower) ? info0.txpower : (finite(rIw.txpower) ? rIw.txpower : null),
|
||||
txpowerCfg: cfg.txpower != null && isFinite(+cfg.txpower) ? +cfg.txpower : null,
|
||||
noise: wifi.noise(finite(info0.noise) ? info0.noise : rIw.noise),
|
||||
noiseRaw: finite(info0.noise) ? info0.noise : (finite(rIw.noise) ? rIw.noise : null),
|
||||
country: cfg.country || rIw.country || null,
|
||||
hwmodes: arr(rIw.hwmodes || info0.hwmodes).join('/'),
|
||||
airtime: null,
|
||||
ifnames: [],
|
||||
ssids: [],
|
||||
clients: 0,
|
||||
legacy: 0
|
||||
};
|
||||
radio.spectrum = wifi.spectrum(band, chan, radio.centre, radio.width);
|
||||
|
||||
ifs.forEach(function(i) {
|
||||
var ic = obj(i.config), ifname = typeof i.ifname === 'string' ? i.ifname : null;
|
||||
/* luci.vantage wireless has no iwinfo part: the view's
|
||||
per-interface iwinfo info replies stand in for it */
|
||||
var iiw = obj(i.iwinfo || (ifname && own(iw, ifname) ? iw[ifname] : null));
|
||||
if (ifname) { radio.ifnames.push(ifname); wlNames.push(ifname); }
|
||||
var st = ifname ? obj(hstat[ifname]) : {};
|
||||
var air = obj(st.airtime);
|
||||
if (num(air.time) > 0 || num(air.utilization) > 0)
|
||||
radio.airtime = Math.max(radio.airtime || 0, Math.min(100, num(air.utilization) / 2.55));
|
||||
var ss = {
|
||||
id: ifname || (rname + ':' + (i.section || ssids.length)),
|
||||
ifname: ifname,
|
||||
section: typeof i.section === 'string' ? i.section : null,
|
||||
radio: rname,
|
||||
band: band,
|
||||
ssid: fmt.safeText(ic.ssid || iiw.ssid || '', 32),
|
||||
mode: ic.mode || 'ap',
|
||||
hidden: ic.hidden === true || ic.hidden === '1',
|
||||
security: wifi.security(ic.encryption),
|
||||
pmf: +ic.ieee80211w || 0,
|
||||
networks: arr(ic.network),
|
||||
bssid: fmt.mac(iiw.bssid || ic.macaddr || ''),
|
||||
up: radio.up && !!ifname && (!devs[ifname] || devs[ifname].up !== false),
|
||||
clients: 0
|
||||
};
|
||||
radio.ssids.push(ss.id);
|
||||
ssids.push(ss);
|
||||
});
|
||||
radios.push(radio);
|
||||
});
|
||||
|
||||
var uplink = this.resolveUplink(raw.ifaces, devs, wlNames);
|
||||
var subnets = uplink ? uplink.ipv4 : [];
|
||||
|
||||
function hintOf(mac) { return obj(hints[mac.toUpperCase()] || hints[mac]); }
|
||||
|
||||
/* mDNS names only where they are unambiguous across all stations */
|
||||
var everyone = [];
|
||||
ssids.forEach(function(ss) {
|
||||
if (ss.ifname) arr(assoc[ss.ifname]).forEach(function(s) {
|
||||
var mac = s && names.normMac(s.mac);
|
||||
if (mac) everyone.push({ mac: mac, ips: self.hintAddrs(hintOf(mac), subnets) });
|
||||
});
|
||||
});
|
||||
var mdns = names.mdnsForStations(raw.mdns, everyone);
|
||||
|
||||
ssids.forEach(function(ss) {
|
||||
if (!ss.ifname) return;
|
||||
var hc = obj(obj(hapd[ss.ifname]).clients);
|
||||
var hmap = Object.create(null);
|
||||
Object.keys(hc).forEach(function(k) { var m = names.normMac(k); if (m) hmap[m] = obj(hc[k]); });
|
||||
arr(assoc[ss.ifname]).forEach(function(s) {
|
||||
var mac = s && names.normMac(s.mac);
|
||||
if (!mac) return;
|
||||
var h = hmap[mac] || {}, rx = obj(s.rx), tx = obj(s.tx);
|
||||
var hint = hintOf(mac);
|
||||
var ips = self.hintAddrs(hint, subnets);
|
||||
var std = wifi.clientStd(rx, tx, h);
|
||||
var alias = raw.aliases && raw.aliases[mac];
|
||||
var nm = names.resolve({
|
||||
mac: mac, alias: alias, ips: ips, rdns: raw.rdns, mdns: mdns,
|
||||
hint: hint, wps: names.wpsName(h.signature),
|
||||
vendor: typeof raw.vendor === 'function' ? raw.vendor(names.ouiKey(mac)) : null
|
||||
});
|
||||
var radio = radios.filter(function(r) { return r.id === ss.radio; })[0];
|
||||
var c = {
|
||||
mac: mac,
|
||||
macLabel: fmt.mac(mac),
|
||||
name: nm.name,
|
||||
nameSource: nm.source,
|
||||
nameSourceLabel: nm.sourceLabel,
|
||||
icon: nm.icon,
|
||||
secondary: nm.secondary,
|
||||
isPrivate: nm.isPrivate,
|
||||
alias: alias ? alias.name : null,
|
||||
aliasSid: alias ? alias.sid : null,
|
||||
vendor: nm.source === 'vendor' ? (typeof raw.vendor === 'function' ? raw.vendor(names.ouiKey(mac)) : null) : null,
|
||||
wps: names.wpsName(h.signature),
|
||||
ips: ips,
|
||||
ifname: ss.ifname,
|
||||
ssid: ss.ssid,
|
||||
ssidId: ss.id,
|
||||
radio: ss.radio,
|
||||
band: ss.band,
|
||||
bandLabel: wifi.bandLabel(ss.band),
|
||||
signal: finite(s.signal) && s.signal < 0 ? s.signal : null,
|
||||
signalAvg: finite(s.signal_avg) && s.signal_avg < 0 ? s.signal_avg : null,
|
||||
noise: wifi.noise(s.noise),
|
||||
std: std,
|
||||
gen: wifi.genName(std, ss.band),
|
||||
rx: wifi.phy(rx),
|
||||
tx: wifi.phy(tx),
|
||||
rxBytes: num(rx.bytes), txBytes: num(tx.bytes),
|
||||
rxPackets: num(rx.packets), txPackets: num(tx.packets),
|
||||
txRetries: num(tx.retries), txFailed: num(tx.failed),
|
||||
connected: finite(s.connected_time) ? s.connected_time : null,
|
||||
inactive: finite(s.inactive) ? s.inactive : null,
|
||||
authorized: s.authorized !== false,
|
||||
mfp: !!(s.mfp || h.mfp),
|
||||
wmm: !!(s.wme || h.wmm),
|
||||
caps: { ht: !!h.ht, vht: !!h.vht, he: !!h.he, eht: !!h.eht, mbo: !!h.mbo, rrm: arr(h.rrm).some(function(v) { return +v > 0; }) }
|
||||
};
|
||||
c.level = wifi.signal(c.signal);
|
||||
c.legacyOnRadio = !!(radio && radio.std && wifi.genRank(c.gen) >= 0 && wifi.genRank(c.gen) < wifi.genRank(wifi.genName(radio.std, radio.band)) &&
|
||||
wifi.genRank(c.gen) <= 4 && wifi.genRank(wifi.genName(radio.std, radio.band)) >= 6);
|
||||
if (radio) { radio.clients++; if (c.legacyOnRadio) radio.legacy++; }
|
||||
ss.clients++;
|
||||
clients.push(c);
|
||||
});
|
||||
});
|
||||
|
||||
var mem = this.memory(info.memory);
|
||||
var release = obj(board.release);
|
||||
return {
|
||||
device: {
|
||||
model: this.deviceModel(board) || _('OpenWrt device'),
|
||||
hostname: fmt.safeText(board.hostname || '', 64) || null,
|
||||
firmware: release.description || [ release.distribution, release.version ].filter(Boolean).join(' ') || null,
|
||||
version: release.version || null,
|
||||
target: release.target || null,
|
||||
kernel: board.kernel || null,
|
||||
system: board.system || null,
|
||||
uptime: finite(info.uptime) ? info.uptime : null,
|
||||
localtime: finite(info.localtime) ? info.localtime : null,
|
||||
load: arr(info.load).slice(0, 3).map(num),
|
||||
memory: mem,
|
||||
swap: this.swap(info.swap),
|
||||
storage: [ this.mount(info.root, _('Root (overlay)')), this.mount(info.tmp, _('Temporary (RAM)')) ].filter(Boolean)
|
||||
},
|
||||
uplink: uplink,
|
||||
radios: radios,
|
||||
ssids: ssids,
|
||||
clients: clients
|
||||
};
|
||||
},
|
||||
|
||||
/* SSIDs with the same name and security across radios -> one entry
|
||||
{ key, ssid, security, bands: [..], ids: [..], clients } */
|
||||
groupSsids: function(ssids) {
|
||||
var out = [], idx = Object.create(null);
|
||||
arr(ssids).forEach(function(s) {
|
||||
var k = s.ssid + '\u0000' + s.security.short;
|
||||
if (!idx[k]) { idx[k] = { key: k, ssid: s.ssid, security: s.security, pmf: s.pmf, hidden: s.hidden, bands: [], ids: [], radios: [], clients: 0, up: false }; out.push(idx[k]); }
|
||||
var g = idx[k];
|
||||
if (s.band && g.bands.indexOf(s.band) < 0) g.bands.push(s.band);
|
||||
if (g.radios.indexOf(s.radio) < 0) g.radios.push(s.radio);
|
||||
g.ids.push(s.id);
|
||||
g.clients += s.clients;
|
||||
g.up = g.up || s.up;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require vantage.fmt as fmt';
|
||||
|
||||
/* Client naming. An access point that is not the DHCP server knows almost
|
||||
nothing about its stations: no hostnames, often not even an address, and
|
||||
most phones use randomised ("private") MAC addresses. This module turns
|
||||
whatever is available into one human label plus the source it came
|
||||
from, in a fixed order of trust:
|
||||
|
||||
alias the user's own name (uci vantage, section type client)
|
||||
dns reverse DNS of the station's address (network.rrdns)
|
||||
dhcp a hostname in LuCI's host hints (DHCP lease on routers);
|
||||
keyed by the station's own MAC
|
||||
mdns mDNS host announcement (umdns hosts). Unauthenticated, and
|
||||
umdns does not say who sent it, so any host on the LAN can
|
||||
claim any address: used only below DNS and DHCP, and only
|
||||
when uncontested (one name for the address, one station for
|
||||
the name; see mdnsMap and mdnsForStations)
|
||||
wps the device name the station sends in its WPS element
|
||||
vendor manufacturer from the OUI of a globally unique MAC
|
||||
private "Private device" for a locally administered MAC
|
||||
mac "Device" as the last resort
|
||||
|
||||
Pure functions; names are untrusted and only ever rendered as text. */
|
||||
|
||||
var NAME_MAX = 48;
|
||||
/* client sections read from /etc/config/vantage; the rpcd plugin refuses
|
||||
to store more */
|
||||
var ALIAS_MAX = 512;
|
||||
|
||||
/* C0 (incl. tab, CR, LF), DEL and C1 controls */
|
||||
var CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
||||
/* a high surrogate not followed by a low one, or a low one not preceded
|
||||
by a high one */
|
||||
var LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(^|[^\ud800-\udbff])[\udc00-\udfff]/;
|
||||
/* space characters that collapse to one U+0020 (controls are rejected
|
||||
before this applies) */
|
||||
var SPACES = /[\u0020\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]+/g;
|
||||
|
||||
var SOURCES = {
|
||||
alias: _('Alias'), dns: _('DNS'), mdns: _('mDNS'), dhcp: _('DHCP'), wps: _('WPS'),
|
||||
vendor: _('Vendor'), 'private': _('Private'), mac: _('MAC')
|
||||
};
|
||||
|
||||
/* device icons the user can pick; also guessed from names */
|
||||
var ICONS = [ 'phone', 'laptop', 'tablet', 'desktop', 'tv', 'speaker', 'printer', 'camera', 'console', 'watch', 'iot', 'router', 'device' ];
|
||||
|
||||
var GUESS = [
|
||||
[ /\b(print|printer|envy|officejet|deskjet|laserjet|pixma|ecotank|mfc-|hl-|brother|epson|canon)/i, 'printer' ],
|
||||
[ /\b(iphone|pixel|galaxy|android|phone|redmi|oneplus|xperia|moto)/i, 'phone' ],
|
||||
[ /\b(ipad|tablet|tab\b|kindle|fire hd)/i, 'tablet' ],
|
||||
[ /\b(macbook|laptop|notebook|thinkpad|surface|chromebook|xps|zenbook|ideapad)/i, 'laptop' ],
|
||||
[ /\b(imac|desktop|workstation|\bpc\b|mac mini|nuc)/i, 'desktop' ],
|
||||
[ /\b(tv|bravia|roku|chromecast|fire ?tv|shield|apple ?tv|webos|tizen)/i, 'tv' ],
|
||||
[ /\b(echo|sonos|homepod|speaker|nest mini|google home)/i, 'speaker' ],
|
||||
[ /\b(cam|camera|doorbell|ring|arlo|wyze|eufy)/i, 'camera' ],
|
||||
[ /\b(playstation|ps[345]|xbox|nintendo|switch|steam ?deck)/i, 'console' ],
|
||||
[ /\b(watch)/i, 'watch' ],
|
||||
[ /\b(esp[-_]?\d|shelly|tasmota|sonoff|tuya|hue|plug|bulb|thermostat|sensor)/i, 'iot' ],
|
||||
[ /\b(router|access point|\bap\b|openwrt|fritz|repeater|mesh)/i, 'router' ]
|
||||
];
|
||||
|
||||
function str(v) { return typeof v === 'string' ? v : ''; }
|
||||
|
||||
return baseclass.extend({
|
||||
NAME_MAX: NAME_MAX,
|
||||
ALIAS_MAX: ALIAS_MAX,
|
||||
SOURCES: SOURCES,
|
||||
ICONS: ICONS,
|
||||
|
||||
/* '02-00-5E-00-53-0F' / '0200.5e00.530f' / '02005e00530f' ->
|
||||
'02:00:5e:00:53:0f' (one separator throughout), else null */
|
||||
normMac: function(mac) {
|
||||
var hex = str(mac).trim().toLowerCase();
|
||||
if (/^[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\1){4}[0-9a-f]{2}$/.test(hex)) return hex.replace(/-/g, ':');
|
||||
if (/^[0-9a-f]{4}\.[0-9a-f]{4}\.[0-9a-f]{4}$/.test(hex) || /^[0-9a-f]{12}$/.test(hex)) {
|
||||
hex = hex.replace(/\./g, '');
|
||||
return hex.match(/../g).join(':');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/* locally administered (U/L bit set): randomised / private address */
|
||||
isPrivateMac: function(mac) {
|
||||
var m = this.normMac(mac);
|
||||
return !!m && (parseInt(m.slice(0, 2), 16) & 2) === 2;
|
||||
},
|
||||
|
||||
/* group bit set: not a station address */
|
||||
isMulticastMac: function(mac) {
|
||||
var m = this.normMac(mac);
|
||||
return !!m && (parseInt(m.slice(0, 2), 16) & 1) === 1;
|
||||
},
|
||||
|
||||
/* 'AA:BB:CC' key for the vendor table, or null for private/multicast */
|
||||
ouiKey: function(mac) {
|
||||
var m = this.normMac(mac);
|
||||
if (!m || this.isPrivateMac(m) || this.isMulticastMac(m)) return null;
|
||||
return m.slice(0, 8).toUpperCase();
|
||||
},
|
||||
|
||||
/* hostapd client signature "wifi4|probe:...,wps:Model_Name|assoc:..."
|
||||
-> 'Model Name' (hostapd replaces spaces with '_') */
|
||||
wpsName: function(signature) {
|
||||
var m = str(signature).match(/(?:^|[|,:])wps:([^|,]{1,64})/);
|
||||
if (!m) return null;
|
||||
var name = m[1].replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return /[A-Za-z0-9]/.test(name) ? fmt.safeText(name, NAME_MAX) : null;
|
||||
},
|
||||
|
||||
/* 'phone.lan.' -> 'phone'; keeps names that are not below a local
|
||||
search domain as they are */
|
||||
cleanHostname: function(name) {
|
||||
var n = str(name).trim().replace(/\.$/, '');
|
||||
if (!n || /^[\d.]+$/.test(n) || /:/.test(n)) return null; /* an address, not a name */
|
||||
n = n.replace(/\.(lan|local|home|localdomain|home\.arpa|internal|fritz\.box|station)$/i, '');
|
||||
return n ? fmt.safeText(n, NAME_MAX) : null;
|
||||
},
|
||||
|
||||
/* User alias from the inspector. Rejected: anything that is not a
|
||||
string, control characters (C0 including tab and newline, DEL, C1),
|
||||
invisible/bidi/format characters (fmt.safeText's set) and unpaired
|
||||
surrogates; these are checked before any normalisation so nothing
|
||||
is silently turned into a space. Then runs of spaces (U+0020 and the
|
||||
Unicode space separators) collapse to one space, the ends are
|
||||
trimmed, and at most NAME_MAX code points are allowed.
|
||||
-> { ok, value } or { ok: false, error } ; '' clears. */
|
||||
validateAlias: function(name) {
|
||||
if (name == null) name = '';
|
||||
if (typeof name !== 'string') return { ok: false, error: _('Name must be text') };
|
||||
if (name.length > NAME_MAX * 8) return { ok: false, error: _('Name is longer than %d characters').format(NAME_MAX) };
|
||||
if (CONTROL.test(name) || LONE_SURROGATE.test(name) || fmt.safeText(name, name.length + 2) !== name)
|
||||
return { ok: false, error: _('Name contains invisible or control characters') };
|
||||
var s = name.replace(SPACES, ' ').trim();
|
||||
if (!s) return { ok: true, value: '' };
|
||||
if (Array.from(s).length > NAME_MAX) return { ok: false, error: _('Name is longer than %d characters').format(NAME_MAX) };
|
||||
return { ok: true, value: s };
|
||||
},
|
||||
|
||||
/* The uci change that saves a station name, re-validating everything
|
||||
whatever the form did: the MAC must normalise to a unicast station
|
||||
address, the name must pass validateAlias, an icon outside ICONS is
|
||||
dropped, and an existing section id must be a plain uci identifier.
|
||||
existing: the aliasMap entry for this MAC, or null.
|
||||
-> { ok: true, op: 'none' | 'add' | 'set' | 'delete', sid, values }
|
||||
| { ok: false, error } */
|
||||
aliasOp: function(existing, mac, name, icon) {
|
||||
var m = this.normMac(mac);
|
||||
if (!m || this.isMulticastMac(m)) return { ok: false, error: _('Not a station MAC address') };
|
||||
var v = this.validateAlias(name);
|
||||
if (!v.ok) return v;
|
||||
var sid = null;
|
||||
if (existing) {
|
||||
sid = typeof existing.sid === 'string' && /^[A-Za-z0-9_]{1,64}$/.test(existing.sid) ? existing.sid : null;
|
||||
if (!sid) return { ok: false, error: _('The stored name has an invalid section id') };
|
||||
}
|
||||
var ic = this.validIcon(icon);
|
||||
if (!v.value) return sid ? { ok: true, op: 'delete', sid: sid } : { ok: true, op: 'none' };
|
||||
if (sid) return { ok: true, op: 'set', sid: sid, values: { name: v.value, icon: ic || '' } };
|
||||
var values = { mac: m, name: v.value };
|
||||
if (ic) values.icon = ic;
|
||||
return { ok: true, op: 'add', values: values };
|
||||
},
|
||||
|
||||
validIcon: function(icon) { return ICONS.indexOf(icon) >= 0 ? icon : null; },
|
||||
|
||||
guessIcon: function(name) {
|
||||
var n = str(name);
|
||||
for (var i = 0; i < GUESS.length; i++) if (GUESS[i][0].test(n)) return GUESS[i][1];
|
||||
return null;
|
||||
},
|
||||
|
||||
/* uci vantage -> { 'aa:bb:..': { sid, name, icon } }; invalid MACs and
|
||||
empty names are skipped, the first section for a MAC wins, sections
|
||||
past ALIAS_MAX are ignored */
|
||||
aliasMap: function(sections) {
|
||||
var out = Object.create(null), self = this, n = 0;
|
||||
(Array.isArray(sections) ? sections : []).forEach(function(s) {
|
||||
if (!s || s['.type'] !== 'client' || ++n > ALIAS_MAX) return;
|
||||
var mac = self.normMac(s.mac), v = self.validateAlias(s.name);
|
||||
if (!mac || out[mac] || !v.ok || !v.value) return;
|
||||
out[mac] = { sid: str(s['.name']), name: v.value, icon: self.validIcon(s.icon) };
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
/* umdns hosts reply { 'name.local': { ipv4: '...', ipv6: '...' } }
|
||||
-> { ip: name }. An address claimed by two different names is left
|
||||
out: umdns caches unsolicited answers from anyone, so a second claim
|
||||
is either a conflict or a spoof, and neither names the station. */
|
||||
mdnsMap: function(hosts) {
|
||||
var out = Object.create(null), bad = Object.create(null), self = this;
|
||||
if (!hosts || typeof hosts !== 'object') return out;
|
||||
Object.keys(hosts).forEach(function(host) {
|
||||
var h = hosts[host], name = self.cleanHostname(host);
|
||||
if (!name || !h || typeof h !== 'object') return;
|
||||
[ 'ipv4', 'ipv6' ].forEach(function(k) {
|
||||
var v = h[k];
|
||||
(Array.isArray(v) ? v : [ v ]).forEach(function(ip) {
|
||||
if (typeof ip !== 'string' || !ip || bad[ip]) return;
|
||||
if (!out[ip]) out[ip] = name;
|
||||
else if (out[ip] !== name) { delete out[ip]; bad[ip] = true; }
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
/* The part of an mdnsMap that may name these stations
|
||||
([ { mac, ips } ]): an address that belongs to more than one station
|
||||
names none of them, and a name that would land on more than one
|
||||
station is dropped for all of them, so one announcement cannot label
|
||||
a second device. -> { ip: name } */
|
||||
mdnsForStations: function(map, stations) {
|
||||
var owners = Object.create(null), byName = Object.create(null), out = Object.create(null), self = this;
|
||||
if (!map) return out;
|
||||
(Array.isArray(stations) ? stations : []).forEach(function(s) {
|
||||
var mac = s && self.normMac(s.mac);
|
||||
if (!mac) return;
|
||||
(Array.isArray(s.ips) ? s.ips : []).forEach(function(ip) {
|
||||
if (typeof ip !== 'string') return;
|
||||
var o = owners[ip] || (owners[ip] = []);
|
||||
if (o.indexOf(mac) < 0) o.push(mac);
|
||||
});
|
||||
});
|
||||
Object.keys(owners).forEach(function(ip) {
|
||||
if (owners[ip].length !== 1 || !Object.prototype.hasOwnProperty.call(map, ip)) return;
|
||||
var name = map[ip], macs = byName[name] || (byName[name] = []);
|
||||
if (macs.indexOf(owners[ip][0]) < 0) macs.push(owners[ip][0]);
|
||||
});
|
||||
Object.keys(owners).forEach(function(ip) {
|
||||
if (owners[ip].length === 1 && Object.prototype.hasOwnProperty.call(map, ip) && byName[map[ip]].length === 1) out[ip] = map[ip];
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
/* Resolve the display name of one station.
|
||||
s: { mac, alias: { name, icon }, ips: [..], rdns: { ip: name },
|
||||
mdns: { ip: name }, hint: { name }, wps, vendor }
|
||||
-> { name, source, sourceLabel, icon, secondary, isPrivate } */
|
||||
resolve: function(s) {
|
||||
s = s || {};
|
||||
var self = this, mac = this.normMac(s.mac) || str(s.mac);
|
||||
var ips = Array.isArray(s.ips) ? s.ips.filter(function(ip) { return typeof ip === 'string' && ip; }) : [];
|
||||
var priv = this.isPrivateMac(mac), name = null, source = null;
|
||||
|
||||
function fromIps(map) {
|
||||
if (!map) return null;
|
||||
for (var i = 0; i < ips.length; i++) {
|
||||
var n = Object.prototype.hasOwnProperty.call(map, ips[i]) ? self.cleanHostname(map[ips[i]]) : null;
|
||||
if (n) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var alias = s.alias && this.validateAlias(s.alias.name);
|
||||
if (alias && alias.ok && alias.value) { name = alias.value; source = 'alias'; }
|
||||
if (!name && (name = fromIps(s.rdns))) source = 'dns';
|
||||
if (!name && s.hint && (name = this.cleanHostname(s.hint.name))) source = 'dhcp';
|
||||
if (!name && (name = fromIps(s.mdns))) source = 'mdns';
|
||||
if (!name && s.wps && (name = fmt.safeText(str(s.wps).trim(), NAME_MAX) || null)) source = 'wps';
|
||||
|
||||
var tail = fmt.macTail(mac);
|
||||
if (!name) {
|
||||
if (priv) { name = _('Private device') + ' · ' + tail; source = 'private'; }
|
||||
else if (s.vendor) { name = _('%s device').format(fmt.safeText(s.vendor, 32)) + ' · ' + tail; source = 'vendor'; }
|
||||
else { name = _('Device') + ' · ' + tail; source = 'mac'; }
|
||||
}
|
||||
|
||||
var icon = (s.alias && this.validIcon(s.alias.icon)) || this.guessIcon(source === 'vendor' ? s.vendor : name) || 'device';
|
||||
return {
|
||||
name: name,
|
||||
source: source,
|
||||
sourceLabel: SOURCES[source],
|
||||
icon: icon,
|
||||
/* second line: the address when there is one, else the MAC */
|
||||
secondary: ips.length ? ips[0] : fmt.mac(mac),
|
||||
isPrivate: priv
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* Compact manufacturer table (OUI -> vendor) for station naming.
|
||||
|
||||
Loaded lazily by the dashboard, only when a station has a globally
|
||||
unique MAC and no better name. Deliberately small and best-effort: a
|
||||
few hundred prefixes of vendors common on home and small-office
|
||||
networks, written from the public IEEE registry. Unknown prefixes just
|
||||
fall back to "Device". A hit is shown with the source tag "Vendor", so
|
||||
it reads as a hint, not a fact.
|
||||
|
||||
Grouped per vendor as space-separated 6-hex-digit prefixes. */
|
||||
|
||||
var TABLE = {
|
||||
'Apple': '000393 000502 000A27 000A95 000D93 0010FA 001124 001451 0016CB 0017F2 0019E3 001B63 001CB3 001D4F 001E52 001EC2 001F5B 001FF3 0021E9 002241 002312 002332 00236C 0023DF 002436 002500 00254B 0025BC 002608 00264A 0026B0 0026BB 003065 0050E4 0056CD 006171 008865 00B362 00C610 00CDFE 00DB70 00F4B9 00F76F 040CCE 041552 041E64 042665 04489A 044BED 0452F3 045453 0469F8 04D3CF 04DB56 04E536 04F13E 04F7E4 086698 086D41 087045 0C74C2 1040F3 10417F 1093E9 10DDB1 14109F 145A05 148FC6 182032 183451 186590 18AF61 18E7F4 1C1AC0 1CABA7 2078F0 20A2E4 24A074 24A2E1 283737 286ABA 28CFDA 28CFE9 28E02C 28E7CF 2CBE08 3010E4 34159E 34C059 38C986 3C0754 3C15C2 403004 406C8F 40A6D9 442A60 48437C 4C8D79 50EAD6 542696 54724F 5855CA 5C5948 5CF938 60334B 60C547 60FB42 64200C 64B9E8 68A86D 6C4008 705681 70CD60 74E1B6 7831C1 78CA39 7C6D62 7CC3A1 80929F 843835 848506 881FA1 885395 8C2DAA 8C5877 9027E4 907240 9803D8 98FE94 9C207B A45E60 A4B197 A82066 A886DD AC87A3 B03495 B817C2 B8C75D B8F6B1 BC52B7 C0847A C82A14 C8BCC8 CC08E0 D023DB D49A20 D83062 D8A25E DC2B61 E0F847 E4CE8F E80688 F0B479 F41BA1 F81EDF',
|
||||
'Samsung': '0000F0 0007AB 001247 0012FB 001377 001599 001632 00166B 00166C 0017C9 0017D5 0018AF 001A8A 001B98 001C43 001D25 001E7D 001FCC 002119 002339 002454 002490 002637 08D42B 101DC0 1449E0 183A2D 1C62B8 244B81 28987B 30074D 3423BA 38AA3C 400E85 5001BB 5C0A5B 78471D 8425DB 8C7712 94350A A00BBA BC20A4 C4576E D022BE E4121D F025B7 F47B5E FCA13E',
|
||||
'Google': '001A11 1CF29A 3C5AB4 48D6D5 546009 94EB2C A47733 D86C63 F4F5D8 F4F5E8',
|
||||
'Amazon': '00FC8B 0C47C9 10CEA9 34D270 38F73D 40B4CD 44650D 50DCE7 6837E9 6854FD 74C246 84D6D0 8871E5 A002DC AC63BE B47C9C F0272D FC65DE FCA183',
|
||||
'Raspberry Pi': 'B827EB DCA632 E45F01 D83ADD 28CDC1 2CCF67',
|
||||
'Espressif': '240AC4 2462AB 246F28 30AEA4 3C71BF 4C11AE 5CCF7F 600194 807D3A 840D8E 84F3EB 8CAAB5 A020A6 A4CF12 AC67B2 B4E62D BCDDC2 C44F33 CC50E3 D8A01D DC4F22 ECFABC 18FE34 68C63A 98F4AB 7C9EBD 94B97E E8DB84 34865D 483FDA 10521C 083AF2 C82B96 40F520 58BF25 3C6105 308398',
|
||||
'Intel': '0002B3 000347 000423 0007E9 000CF1 000E0C 000E35 001111 0012F0 001302 001320 0013CE 0013E8 001500 00166F 001676 0016EA 0016EB 0018DE 0019D1 0019D2 001B21 001B77 001CBF 001CC0 001DE0 001DE1 001E64 001E65 001E67 001F3B 001F3C 00215C 00215D 00216A 00216B 0022FA 0022FB 002314 002315 0024D6 0024D7 0026C6 0026C7 002710 3CA9F4 4C3488 605718 6805CA 7C5CF8 8086F2 8CA982 94659C A08869 A44E31 AC7289 B46BFC DC5360 E8B1FC F81654 3413E8 3C6AA7 4851B7 5C879C 701CE7 843A4B 984FEE A0A8CD C85EA9 D0577B 04D3B0 001517',
|
||||
'HP': '0001E6 0001E7 0002A5 0004EA 000802 000BCD 000D9D 000E7F 000F20 000F61 001083 00110A 001185 001279 001321 001438 0014C2 001560 001635 001708 0017A4 001871 0018FE 0019BB 001A4B 001B78 001CC4 001E0B 001F29 00215A 002264 00237D 002481 0025B3 002655 00306E 0060B0 101F74 10604B 1CC1DE 2C4138 2C44FD 308D99 3863BB 3CD92B 5820B1 645106 6CC217 705A0F 78ACC0 80C16E 9457A5 98E7F4 A01D48 A0D3C1 B499BA C8CBB8 D4C9EF E4115B ECB1D7 F4CE46 FC15B4',
|
||||
'Xiaomi': '009EC8 0C1DAF 102AB3 14F65A 185936 2082C0 286C07 3480B3 38A4ED 3CBD3E 4C49E3 508F4C 584498 640980 64B473 68DFDD 742344 7802F8 7C1DD9 8CBEBE 98FAE3 9C99A0 A086C6 ACC1EE B0E235 C40BCB C46AB7 D4970B F0B429 F48B32 FC64BA 50EC50 7C49EB',
|
||||
'Huawei': '001882 001E10 002568 00259E 00464B 00664B 00E0FC 04C06F 0819A6 0C37DC 101B54 20F3A3 283152 30D17E 404D8E 4846FB 4C5499 548998 5C7D5E 70723C 78D752 80B686 8853D4 8C34FD ACE215 BC7670 C8D15E D07AB5 E0247F F4C714',
|
||||
'TP-Link': '001D0F 0023CD 002586 002719 10FEED 14CC20 14CF92 18A6F7 1C3BF3 246968 30B5C2 3460F9 50C7BF 54C80F 5C628B 60E327 6466B3 647002 6C5AB0 74DA88 788CB5 90F652 98DAC4 A0F3C1 AC84C6 B04E26 B09575 C025E9 C46E1F C83A35 D807B6 E4C32A E848B8 EC086B F4F26D F81A67 503EAA 984827 8416F9 1C61B4 3C846A 9CA2F4 B0BE76 2887BA 5CA6E6 6032B1',
|
||||
'Netgear': '00095B 000FB5 00146C 00184D 001B2F 001E2A 001F33 00223F 0024B2 0026F2 04A151 08BD43 100D7F 204E7F 28C68E 2CB05D 30469A 4494FC 6CB0CE 841B5E 9C3DCF A00460 A021B7 B07FB9 C03F0E C40415 E0469A E091F5',
|
||||
'Ubiquiti': '00156D 002722 0418D6 18E829 24A43C 44D9E7 687251 7483C2 788A20 802AA8 B4FBE4 DC9FDB E063DA F09FC2 FCECDA 245A4C 602232 70A741 942A6F D021F9 E43883',
|
||||
'Zyxel': '001349 0019CB 0023F8 00A0C5 107BEF 404A03 5CF4AB 588BF3 B8ECA3 BC9911 C86C87 CC5D4E E4186B EC43F6 FCF528 90EF68',
|
||||
'AVM': '00040E 246511 3810D5 3CA62F 444E6D 5C4979 7CFF4D 989BCB B0F208 C02506 CCCE1E DC396F E0286D F0B014 2C91AB 3431C4 74427F',
|
||||
'ASUS': '000C6E 000EA6 00112F 0011D8 0013D4 0015F2 001731 0018F3 001A92 001BFC 001D60 001E8C 001FC6 002215 002354 00248C 002618 049226 04D4C4 08606E 10BF48 14DAE9 1C872C 2C4D54 2C56DC 305A3A 382C4A 40167E 50465D 5404A6 6045CB 74D02B 88D7F6 AC220B AC9E17 B06EBF BCEE7B C86000 D850E6 E03F49 F07959 F832E4 04421A 244BFE 7C10C9 A85E45',
|
||||
'Sony': '00041F 001315 0015C1 0019C5 001D0D 001FA7 00248D 00D9D1 280DFC 709E29 A8E3EE BC60A7 F8461C FC0FE6 00EB2D 78C881 2CCC44',
|
||||
'Nintendo': '0009BF 001656 0017AB 00191D 0019FD 001AE9 001B7A 001BEA 001CBE 001DBC 001E35 001EA9 001F32 001FC5 002147 0021BD 00224C 0022AA 0022D7 002331 0023CC 00241E 002444 0024F3 0025A0 002659 002709 0403D6 2C10C1 34AF2C 40F407 58BDA3 7CBB8A 8C56C5 98B6E9 9CE635 A4C0E1 B8AE6E CC9E00 D86BF7 DC68EB E00C7F E84ECE 64B5C6',
|
||||
'Microsoft': '000D3A 00125A 00155D 0017FA 001DD8 002248 0025AE 281878 3059B7 501AC5 6045BD 7C1E52 7CED8D 985FD3 B4AE2B C83F26 DCB4C4 5882A8',
|
||||
'Sonos': '000E58 347E5C 48A6B8 542A1B 5CAAFD 7828CA 949F3E B8E937 F0F6C1 38420B',
|
||||
'Roku': '000D4B 080581 105932 20EFBD B0A737 B83E59 C83A6B D04D2C DC3A5E AC3A7A CC6DA0',
|
||||
'LG': '001C62 001E75 001F6B 001FE3 0021FB 0022A9 002483 0025E5 0026E2 00AA70 10683F 203DBD 2C54CF 344DF7 388C50 40B0FA 58A2B5 64995D 6CD68A 785DC8 88C9D0 A816B2 BCF5AC C49A02 CC2D8C E85B5B F80CF3',
|
||||
'Dell': '00065B 000874 000BDB 000D56 000F1F 001143 00123F 001372 001422 0015C5 00188B 0019B9 001AA0 001C23 001D09 001E4F 002170 00219B 002219 0023AE 0024E8 002564 0026B9 141877 14FEB5 180373 1866DA 18A99B 18DBF2 204747 24B6FD 28F10E 3417EB 34E6D7 44A842 484D7E 4C7625 509A4C 549F35 5C260A 74867A 782BCB 847BEB 90B11C 989096 A41F72 B083FE B82A72 B8AC6F BC305B C81F66 D067E5 D481D7 D4AE52 D4BED9 E0DB55 ECF4BB F01FAF F48E38 F8B156 F8BC12 F8DB88',
|
||||
'Signify (Hue)': '001788 ECB5FA',
|
||||
'Google Nest': '18B430 641666',
|
||||
'Belkin': '94103E EC1A59 08863B 149182',
|
||||
'Canon': '000085 001E8F 180CAC 2C9EFC F48139 888717 74BFC0',
|
||||
'Epson': '000048 0026AB 64EB8C A4EE57 44D244 381A52 9CAED3 AC1826',
|
||||
'Brother': '001BA9 008077 30055C 3C2AF4',
|
||||
'OnePlus': '94652D C0EEFB 64A2F9',
|
||||
'Realtek': '00E04C',
|
||||
'Qualcomm': '00037F 00A0C6',
|
||||
'MediaTek': '000CE7 000C43',
|
||||
'Murata': '000E6D 0026E8 00376D 1C994C 40F308 44A7CF 5CDAD4 6021C0 88308A 90B686 98F170 F02765 FCC2DE',
|
||||
'AzureWave': '0015AF 0025D3 1C4BD6 24FD52 485D60 54271E 6C71D9 74C63B 80A589 94DBC9 B0EE45 D0C5D3 DC85DE E0B9A5',
|
||||
'Synology': '001132',
|
||||
'QNAP': '00089B 245EBE',
|
||||
'Hikvision': '4419B6 BCAD28 C056E3 4CBD8F',
|
||||
'Dahua': '3CEF8C 9002A9 E0508B',
|
||||
'MikroTik': '000C42 4C5E0C 64D154 6C3B6B 744D28 B869F4 CC2DE0 D4CA6D DC2C6E E48D8C 18FD74 2CC81B 488F5A 085531',
|
||||
'Linksys': '00045A 000625 000C41 000F66 001217 001310 0014BF 0016B6 001839 0018F8 001A70 001C10 001D7E 001EE5 002129 00226B 002369 00259C 20AA4B 48F8B3 586D8F 687F74 C0C1C0 C8B373',
|
||||
'D-Link': '00055D 000D88 000F3D 001195 001346 0015E9 00179A 00195B 001B11 001CF0 001E58 002191 0022B0 002401 00265A 14D64D 1C7EE5 28107B 340804 5CD998 78542E 84C9B2 9094E4 B8A386 C0A0BB C4A81D C8BE19 CCB255 F07D68 FC7516',
|
||||
'Cisco': '00000C 000142 001BD4 004096',
|
||||
'Wyze': '2CAA8E 7C78B2',
|
||||
'Tesla': '4CFCAA'
|
||||
};
|
||||
|
||||
var map = null;
|
||||
|
||||
function build() {
|
||||
map = new Map();
|
||||
Object.keys(TABLE).forEach(function(vendor) {
|
||||
TABLE[vendor].split(' ').forEach(function(p) {
|
||||
if (/^[0-9A-F]{6}$/.test(p) && !map.has(p)) map.set(p, vendor);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
/* 'AA:BB:CC' (names.ouiKey) or a full MAC -> vendor name or null */
|
||||
lookup: function(key) {
|
||||
var hex = String(key == null ? '' : key).replace(/[^0-9A-Fa-f]/g, '').slice(0, 6).toUpperCase();
|
||||
if (hex.length !== 6) return null;
|
||||
return (map || build()).get(hex) || null;
|
||||
},
|
||||
|
||||
size: function() { return (map || build()).size; }
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* Time series kept in the browser for the session, and the SVG path
|
||||
geometry to draw them. A series is an array of [ t (ms), v0, v1, ... ]
|
||||
in strictly increasing t. Pure functions. */
|
||||
|
||||
function finite(v) { return typeof v === 'number' && isFinite(v); }
|
||||
function r1(v) { return Math.round(v * 10) / 10; }
|
||||
|
||||
return baseclass.extend({
|
||||
/* append, keeping entries newer than now - windowMs and at most max */
|
||||
push: function(series, point, windowMs, max) {
|
||||
var out = Array.isArray(series) ? series : [];
|
||||
if (!Array.isArray(point) || !finite(point[0])) return out;
|
||||
if (out.length && out[out.length - 1][0] >= point[0]) return out;
|
||||
out.push(point);
|
||||
var cutoff = point[0] - (windowMs > 0 ? windowMs : Infinity);
|
||||
while (out.length && (out[0][0] < cutoff || (max > 0 && out.length > max))) out.shift();
|
||||
return out;
|
||||
},
|
||||
|
||||
/* Stored series (sessionStorage) are untrusted: keep arrays of exactly
|
||||
`width` finite numbers with values >= 0 (except t), strictly
|
||||
increasing t, nothing after notAfter, at most maxLen (newest). */
|
||||
valid: function(arr, width, maxLen, notAfter) {
|
||||
if (!Array.isArray(arr)) return [];
|
||||
var out = [], lastT = -Infinity;
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
var p = arr[i], ok = Array.isArray(p) && p.length === width;
|
||||
for (var j = 0; ok && j < width; j++) ok = finite(p[j]) && (j === 0 || p[j] >= 0);
|
||||
if (!ok || p[0] <= lastT || (notAfter != null && p[0] > notAfter)) continue;
|
||||
out.push(p.slice());
|
||||
lastT = p[0];
|
||||
}
|
||||
return out.length > maxLen ? out.slice(out.length - maxLen) : out;
|
||||
},
|
||||
|
||||
/* a round axis maximum >= v: 1, 2, 2.5, 5 x 10^n */
|
||||
niceMax: function(v) {
|
||||
if (!finite(v) || v <= 0) return 1;
|
||||
var e = Math.pow(10, Math.floor(Math.log10(v))), f = v / e;
|
||||
var n = f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10;
|
||||
return n * e;
|
||||
},
|
||||
|
||||
/* a round axis maximum >= v with finer steps (1, 1.5, 2, 2.5, 3, 4,
|
||||
5, 6, 8, 10 x 10^n), so a 374 Mbit/s peak gets a 400 axis, not 500 */
|
||||
niceCeil: function(v) {
|
||||
if (!finite(v) || v <= 0) return 1;
|
||||
var e = Math.pow(10, Math.floor(Math.log10(v))), f = v / e;
|
||||
var steps = [ 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10 ];
|
||||
for (var i = 0; i < steps.length; i++)
|
||||
if (f <= steps[i] * (1 + 1e-9)) return steps[i] * e;
|
||||
return 10 * e;
|
||||
},
|
||||
|
||||
/* { now, peak, avg, n } of column idx over points with t >= t0 (now:
|
||||
the newest finite value); nulls when there is no point */
|
||||
stats: function(series, idx, t0) {
|
||||
var n = 0, sum = 0, peak = null, now = null;
|
||||
(series || []).forEach(function(p) {
|
||||
if (!Array.isArray(p) || !finite(p[idx]) || (t0 != null && p[0] < t0)) return;
|
||||
n++; sum += p[idx];
|
||||
if (peak == null || p[idx] > peak) peak = p[idx];
|
||||
now = p[idx];
|
||||
});
|
||||
return { now: now, peak: peak, avg: n ? sum / n : null, n: n };
|
||||
},
|
||||
|
||||
/* Time domain of a chart that fills in while history accumulates:
|
||||
[t1 - span, t1] where span is the age of the oldest point (of any
|
||||
of the series) clamped to [minMs, maxMs]. age: of the oldest point;
|
||||
full: the whole window is covered. -> { t0, t1, span, age, full } */
|
||||
domain: function(list, t1, minMs, maxMs) {
|
||||
var first = Infinity;
|
||||
(list || []).forEach(function(sr) {
|
||||
(sr || []).forEach(function(p) { if (Array.isArray(p) && finite(p[0]) && p[0] < first) first = p[0]; });
|
||||
});
|
||||
var span = isFinite(first) ? Math.max(minMs, Math.min(maxMs, t1 - first)) : minMs;
|
||||
return { t0: t1 - span, t1: t1, span: span, age: isFinite(first) ? Math.min(maxMs, t1 - first) : 0, full: span >= maxMs - 1000 };
|
||||
},
|
||||
|
||||
/* max over columns idx of the series (for a shared y scale) */
|
||||
max: function(series, idx) {
|
||||
var m = 0;
|
||||
(series || []).forEach(function(p) {
|
||||
idx.forEach(function(i) { if (finite(p[i]) && p[i] > m) m = p[i]; });
|
||||
});
|
||||
return m;
|
||||
},
|
||||
|
||||
/* Screen points of column idx within a w x h box: x by time over
|
||||
[t0, t1], y by value over [0, vmax] (0 at the bottom). A gap longer
|
||||
than maxGap ms starts a new segment. -> [ [ [x, y], ... ], ... ] */
|
||||
segments: function(series, idx, t0, t1, vmax, w, h, maxGap) {
|
||||
var segs = [], cur = null, lastT = null, span = (t1 - t0) || 1;
|
||||
vmax = vmax > 0 ? vmax : 1;
|
||||
(series || []).forEach(function(p) {
|
||||
var v = p[idx];
|
||||
if (!finite(v) || p[0] < t0 - (maxGap || 0)) { cur = null; return; }
|
||||
if (cur && maxGap && lastT != null && p[0] - lastT > maxGap) cur = null;
|
||||
if (!cur) { cur = []; segs.push(cur); }
|
||||
var x = (p[0] - t0) / span * w, y = h - Math.min(1, Math.max(0, v / vmax)) * h;
|
||||
cur.push([ r1(x), r1(y) ]);
|
||||
lastT = p[0];
|
||||
});
|
||||
return segs;
|
||||
},
|
||||
|
||||
/* 'M x y L x y ...' for line segments */
|
||||
linePath: function(segs) {
|
||||
return (segs || []).filter(function(s) { return s.length > 0; }).map(function(s) {
|
||||
if (s.length === 1) return 'M' + s[0][0] + ' ' + s[0][1] + 'h0.1';
|
||||
return 'M' + s.map(function(pt) { return pt[0] + ' ' + pt[1]; }).join('L');
|
||||
}).join('');
|
||||
},
|
||||
|
||||
/* closed area down to the baseline y = h */
|
||||
areaPath: function(segs, h) {
|
||||
return (segs || []).filter(function(s) { return s.length > 1; }).map(function(s) {
|
||||
return 'M' + s[0][0] + ' ' + h + 'L' + s.map(function(pt) { return pt[0] + ' ' + pt[1]; }).join('L') +
|
||||
'L' + s[s.length - 1][0] + ' ' + h + 'Z';
|
||||
}).join('');
|
||||
},
|
||||
|
||||
/* index of the point closest in time to t (for hover), or -1 */
|
||||
nearest: function(series, t) {
|
||||
var best = -1, bd = Infinity;
|
||||
(series || []).forEach(function(p, i) {
|
||||
var d = Math.abs(p[0] - t);
|
||||
if (d < bd) { bd = d; best = i; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require vantage.fmt as fmt';
|
||||
|
||||
/* Wi-Fi vocabulary: bands, channel widths, generations, signal levels,
|
||||
security labels and channel geometry. Pure functions. */
|
||||
|
||||
function num(v) { v = +v; return isFinite(v) ? v : 0; }
|
||||
function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); }
|
||||
|
||||
var BANDS = {
|
||||
'2g': { label: _('2.4 GHz'), short: '2.4', lo: 2400, hi: 2495, base: 2407 },
|
||||
'5g': { label: _('5 GHz'), short: '5', lo: 5150, hi: 5895, base: 5000 },
|
||||
'6g': { label: _('6 GHz'), short: '6', lo: 5925, hi: 7125, base: 5950 },
|
||||
'60g': { label: _('60 GHz'), short: '60', lo: 57240, hi: 70200, base: 56160 }
|
||||
};
|
||||
|
||||
/* rank for comparisons; 'legacy' is 802.11a/b/g */
|
||||
var GEN_RANK = { 'legacy': 0, 'Wi-Fi 4': 4, 'Wi-Fi 5': 5, 'Wi-Fi 6': 6, 'Wi-Fi 6E': 6.5, 'Wi-Fi 7': 7 };
|
||||
|
||||
/* UCI encryption -> friendly label. Suffixes like +ccmp are cipher
|
||||
choices, not a different protocol. */
|
||||
var SECURITY = [
|
||||
[ /^none$/, _('Open'), _('Open network'), 'open' ],
|
||||
[ /^owe$/, _('OWE'), _('Enhanced Open (OWE)'), 'owe' ],
|
||||
[ /^sae-mixed$/, _('WPA2/3'), _('WPA2/WPA3-Personal'), 'ok' ],
|
||||
[ /^sae(-ext)?$/, _('WPA3'), _('WPA3-Personal'), 'ok' ],
|
||||
[ /^psk-mixed$/, _('WPA/WPA2'), _('WPA/WPA2-Personal (TKIP allowed)'), 'weak' ],
|
||||
[ /^psk2$/, _('WPA2'), _('WPA2-Personal'), 'ok' ],
|
||||
[ /^psk$/, _('WPA'), _('WPA-Personal (legacy)'), 'weak' ],
|
||||
[ /^wpa3-mixed$/, _('WPA2/3-Ent'), _('WPA2/WPA3-Enterprise'), 'ok' ],
|
||||
[ /^wpa3(-192)?$/, _('WPA3-Ent'), _('WPA3-Enterprise'), 'ok' ],
|
||||
[ /^wpa-mixed$/, _('WPA/WPA2-Ent'), _('WPA/WPA2-Enterprise'), 'weak' ],
|
||||
[ /^wpa2$/, _('WPA2-Ent'), _('WPA2-Enterprise'), 'ok' ],
|
||||
[ /^wpa$/, _('WPA-Ent'), _('WPA-Enterprise (legacy)'), 'weak' ],
|
||||
[ /^wep/, _('WEP'), _('WEP (insecure)'), 'weak' ]
|
||||
];
|
||||
|
||||
return baseclass.extend({
|
||||
BANDS: BANDS,
|
||||
|
||||
/* '2g' / frequency (MHz) -> '2g' | '5g' | '6g' | '60g' | null */
|
||||
bandKey: function(cfgBand, freq) {
|
||||
if (typeof cfgBand === 'string' && own(BANDS, cfgBand)) return cfgBand;
|
||||
freq = num(freq);
|
||||
if (!freq) return null;
|
||||
return freq < 2500 ? '2g' : freq < 5925 ? '5g' : freq < 7200 ? '6g' : '60g';
|
||||
},
|
||||
|
||||
bandLabel: function(key) {
|
||||
return own(BANDS, key) ? BANDS[key].label : fmt.DASH;
|
||||
},
|
||||
|
||||
/* 'EHT160' -> { std: 'EHT', gen: 'Wi-Fi 7', width: 160 } */
|
||||
htmode: function(mode) {
|
||||
var m = String(mode || '').toUpperCase().match(/^(EHT|HE|VHT|HT|NOHT)(\d+)?/);
|
||||
if (!m) return { std: null, gen: null, width: null };
|
||||
var gen = { EHT: 'Wi-Fi 7', HE: 'Wi-Fi 6', VHT: 'Wi-Fi 5', HT: 'Wi-Fi 4', NOHT: 'legacy' }[m[1]];
|
||||
return { std: m[1] === 'NOHT' ? null : m[1], gen: gen, width: m[2] ? +m[2] : (m[1] === 'NOHT' ? 20 : null) };
|
||||
},
|
||||
|
||||
/* Wi-Fi generation, marketing name. HE on 6 GHz is "Wi-Fi 6E". */
|
||||
genName: function(std, band) {
|
||||
switch (std) {
|
||||
case 'EHT': return 'Wi-Fi 7';
|
||||
case 'HE': return band === '6g' ? 'Wi-Fi 6E' : 'Wi-Fi 6';
|
||||
case 'VHT': return 'Wi-Fi 5';
|
||||
case 'HT': return 'Wi-Fi 4';
|
||||
case 'legacy': return 'legacy';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
genRank: function(gen) { return own(GEN_RANK, gen) ? GEN_RANK[gen] : -1; },
|
||||
|
||||
/* std of one iwinfo rate block ({ ht, vht, he, eht, ... }) */
|
||||
rateStd: function(r) {
|
||||
if (!r || typeof r !== 'object') return null;
|
||||
return r.eht ? 'EHT' : r.he ? 'HE' : r.vht ? 'VHT' : r.ht ? 'HT' : 'legacy';
|
||||
},
|
||||
|
||||
/* Best standard a client has shown: capability flags from hostapd
|
||||
(ht/vht/he/eht) and the rate flags of the frames seen by iwinfo.
|
||||
The last frame is often a low-rate management frame, so the
|
||||
maximum is what the client can do. */
|
||||
clientStd: function(rx, tx, caps) {
|
||||
var order = [ 'legacy', 'HT', 'VHT', 'HE', 'EHT' ], best = -1;
|
||||
[ this.rateStd(rx), this.rateStd(tx) ].forEach(function(s) { best = Math.max(best, order.indexOf(s)); });
|
||||
if (caps && typeof caps === 'object') {
|
||||
if (caps.eht) best = Math.max(best, 4);
|
||||
else if (caps.he) best = Math.max(best, 3);
|
||||
else if (caps.vht) best = Math.max(best, 2);
|
||||
else if (caps.ht) best = Math.max(best, 1);
|
||||
}
|
||||
return best < 0 ? null : order[best];
|
||||
},
|
||||
|
||||
/* iwinfo rate block -> { rate (kbit/s), mhz, std, mcs, nss, label } */
|
||||
phy: function(r) {
|
||||
if (!r || typeof r !== 'object') return null;
|
||||
var std = this.rateStd(r), mhz = num(r.mhz) || null, rate = num(r.rate) || null;
|
||||
var parts = [ fmt.phyRate(rate) ];
|
||||
if (mhz) parts.push(_('%s MHz').format(mhz));
|
||||
parts.push(std === 'legacy' ? _('legacy') : std);
|
||||
var detail = [];
|
||||
if (r.mcs != null && isFinite(+r.mcs)) detail.push(_('MCS %d').format(+r.mcs));
|
||||
if (r.nss != null && +r.nss > 0) detail.push((+r.nss) + '×' + (+r.nss));
|
||||
if (r.short_gi || +r.he_gi > 0 || +r.eht_gi > 0) detail.push(_('short GI'));
|
||||
return { rate: rate, mhz: mhz, std: std, mcs: r.mcs != null ? +r.mcs : null, nss: r.nss != null ? +r.nss : null,
|
||||
label: parts.join(' · '), detail: detail.join(' · ') };
|
||||
},
|
||||
|
||||
/* signal (dBm) -> { bars 0..4, level ok|warn|crit|none, word } */
|
||||
signal: function(dbm) {
|
||||
dbm = num(dbm);
|
||||
if (!dbm || dbm >= 0) return { bars: 0, level: 'none', word: _('No signal') };
|
||||
if (dbm >= -55) return { bars: 4, level: 'ok', word: _('Excellent') };
|
||||
if (dbm >= -67) return { bars: 3, level: 'ok', word: _('Good') };
|
||||
if (dbm >= -75) return { bars: 2, level: 'warn', word: _('Fair') };
|
||||
return { bars: 1, level: 'crit', word: _('Weak') };
|
||||
},
|
||||
|
||||
/* ath12k (and some others) report a fixed placeholder (-107..-121 dBm)
|
||||
instead of a measured noise floor. Anything below -105 dBm is not
|
||||
physical for a 20 MHz+ channel: unknown, not a fake SNR. */
|
||||
noise: function(dbm) {
|
||||
dbm = num(dbm);
|
||||
return (dbm < 0 && dbm > -105) ? dbm : null;
|
||||
},
|
||||
|
||||
/* UCI encryption string -> { short, long, tone: ok|weak|open|owe } */
|
||||
security: function(enc) {
|
||||
var e = String(enc == null ? 'none' : enc).trim().toLowerCase().replace(/\+.*$/, '') || 'none';
|
||||
for (var i = 0; i < SECURITY.length; i++)
|
||||
if (SECURITY[i][0].test(e)) return { short: SECURITY[i][1], long: SECURITY[i][2], tone: SECURITY[i][3] };
|
||||
return { short: e.toUpperCase(), long: e, tone: 'ok' };
|
||||
},
|
||||
|
||||
/* channel -> centre frequency (MHz) for the band */
|
||||
chanFreq: function(band, chan) {
|
||||
chan = num(chan);
|
||||
if (!own(BANDS, band) || !chan) return null;
|
||||
if (band === '2g') return chan === 14 ? 2484 : 2407 + 5 * chan;
|
||||
if (band === '60g') return 56160 + 2160 * chan;
|
||||
return BANDS[band].base + 5 * chan;
|
||||
},
|
||||
|
||||
/* Where the occupied channel sits inside its band, as fractions 0..1
|
||||
of the band's span: { from, to, lo, hi, centre } or null. Uses the
|
||||
centre channel of wide channels (iwinfo center_chan1). */
|
||||
spectrum: function(band, chan, centerChan, width) {
|
||||
if (!own(BANDS, band)) return null;
|
||||
var b = BANDS[band];
|
||||
var c = this.chanFreq(band, centerChan) || this.chanFreq(band, chan);
|
||||
width = num(width) || 20;
|
||||
if (!c) return null;
|
||||
var lo = c - width / 2, hi = c + width / 2, span = b.hi - b.lo;
|
||||
var clamp = function(v) { return Math.max(0, Math.min(1, v)); };
|
||||
return { from: clamp((lo - b.lo) / span), to: clamp((hi - b.lo) / span), lo: lo, hi: hi, centre: c, bandLo: b.lo, bandHi: b.hi };
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
||||
# Vantage dashboard settings (luci-app-vantage).
|
||||
#
|
||||
# Station names given in the dashboard's inspector are stored here, one
|
||||
# section per station (MAC in lower case, name at most 48 characters):
|
||||
#
|
||||
# config client
|
||||
# option mac '00:00:5e:00:53:01'
|
||||
# option name 'Living room TV'
|
||||
# option icon 'tv'
|
||||
|
||||
config vantage 'main'
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"admin/dashboard": {
|
||||
"title": "Dashboard",
|
||||
"order": 1,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "vantage/overview"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-vantage" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"luci-app-vantage": {
|
||||
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless)",
|
||||
"read": {
|
||||
"file": {
|
||||
"/proc/stat": [ "read" ]
|
||||
},
|
||||
"ubus": {
|
||||
"file": [ "read" ],
|
||||
"system": [ "board", "info" ],
|
||||
"network.interface": [ "dump" ],
|
||||
"network.device": [ "status" ],
|
||||
"luci-rpc": [ "getHostHints" ],
|
||||
"luci.vantage": [ "wireless" ],
|
||||
"iwinfo": [ "assoclist", "info" ],
|
||||
"hostapd.*": [ "get_clients", "get_status" ],
|
||||
"umdns": [ "hosts" ],
|
||||
"uci": [ "get" ]
|
||||
},
|
||||
"uci": [ "vantage" ]
|
||||
}
|
||||
},
|
||||
"luci-app-vantage-rdns": {
|
||||
"description": "Vantage dashboard: client names from reverse DNS (lets the holder make the device send DNS PTR queries to any DNS server and port it names)",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"network.rrdns": [ "lookup" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
"luci-app-vantage-names": {
|
||||
"description": "Vantage dashboard: name stations (luci.vantage set_alias: validated names in /etc/config/vantage only, at most 512)",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"uci": [ "get" ]
|
||||
},
|
||||
"uci": [ "vantage" ]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.vantage": [ "set_alias" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// luci-app-vantage: server side of the Vantage dashboard.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// ubus object luci.vantage, loaded by rpcd-mod-ucode:
|
||||
//
|
||||
// wireless network.wireless status reduced to the fields the dashboard
|
||||
// reads: no key, sae_password, RADIUS or 802.11r secrets, no
|
||||
// EAP credentials. Read group luci-app-vantage.
|
||||
// set_alias { mac, name, icon }: name one station, or remove its name
|
||||
// with name ''. Validated here, whatever the browser did, and
|
||||
// written to /etc/config/vantage only. Write group
|
||||
// luci-app-vantage-names.
|
||||
//
|
||||
// Keep the validation in step with names.js (validateAlias, normMac,
|
||||
// ICONS) and with dev/replay/vantage-plugin.js; tests/plugin.test.js runs
|
||||
// all three against the same cases.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
import { connect } from 'ubus';
|
||||
|
||||
const CONFIG = 'vantage';
|
||||
const NAME_MAX = 48; // code points, after collapsing spaces
|
||||
const NAME_MAX_UNITS = NAME_MAX * 8; // raw input cap, in UTF-16 code units (as names.js)
|
||||
const MAX_ALIASES = 512; // client sections; bounds /etc/config/vantage
|
||||
const ICONS = [ 'phone', 'laptop', 'tablet', 'desktop', 'tv', 'speaker', 'printer', 'camera', 'console', 'watch', 'iot', 'router', 'device' ];
|
||||
|
||||
/* ------------------------------------------------------------ helpers */
|
||||
|
||||
// '02-00-5E-00-53-0F' / '0200.5e00.530f' / '02005e00530f' -> '02:00:5e:00:53:0f'
|
||||
function norm_mac(v) {
|
||||
if (type(v) != 'string')
|
||||
return null;
|
||||
|
||||
let h = lc(trim(v));
|
||||
|
||||
if (match(h, /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/))
|
||||
return h;
|
||||
|
||||
if (match(h, /^[0-9a-f]{2}(-[0-9a-f]{2}){5}$/))
|
||||
return replace(h, /-/g, ':');
|
||||
|
||||
if (match(h, /^[0-9a-f]{4}\.[0-9a-f]{4}\.[0-9a-f]{4}$/) || match(h, /^[0-9a-f]{12}$/)) {
|
||||
h = replace(h, /\./g, '');
|
||||
return join(':', map(match(h, /../g), (m) => m[0]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function is_multicast(mac) {
|
||||
return (hex(substr(mac, 0, 2)) & 1) == 1;
|
||||
}
|
||||
|
||||
// UTF-8 -> [ [ code point, byte offset, byte length ], ... ]; null when the
|
||||
// bytes are not well-formed UTF-8 (overlong forms and surrogates included)
|
||||
function decode(s) {
|
||||
let out = [], n = length(s), i = 0;
|
||||
|
||||
while (i < n) {
|
||||
let b = ord(s, i), cp, need;
|
||||
|
||||
if (b < 0x80) { cp = b; need = 0; }
|
||||
else if (b >= 0xc2 && b <= 0xdf) { cp = b & 0x1f; need = 1; }
|
||||
else if (b >= 0xe0 && b <= 0xef) { cp = b & 0x0f; need = 2; }
|
||||
else if (b >= 0xf0 && b <= 0xf4) { cp = b & 0x07; need = 3; }
|
||||
else return null;
|
||||
|
||||
if (i + need >= n)
|
||||
return null;
|
||||
|
||||
for (let k = 1; k <= need; k++) {
|
||||
let c = ord(s, i + k);
|
||||
|
||||
if ((c & 0xc0) != 0x80)
|
||||
return null;
|
||||
|
||||
cp = (cp << 6) | (c & 0x3f);
|
||||
}
|
||||
|
||||
if ((need == 2 && cp < 0x800) || (need == 3 && (cp < 0x10000 || cp > 0x10ffff)) || (cp >= 0xd800 && cp <= 0xdfff))
|
||||
return null;
|
||||
|
||||
push(out, [ cp, i, need + 1 ]);
|
||||
i += need + 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// control, invisible, bidi and format characters: the C0/C1 controls plus
|
||||
// fmt.js UNSAFE_TEXT
|
||||
function forbidden(cp) {
|
||||
return cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f) ||
|
||||
cp == 0xad || cp == 0x34f || cp == 0x61c || cp == 0x115f || cp == 0x1160 ||
|
||||
cp == 0x17b4 || cp == 0x17b5 || cp == 0x180e || cp == 0x200b || cp == 0x200e || cp == 0x200f ||
|
||||
(cp >= 0x2028 && cp <= 0x202e) || (cp >= 0x2060 && cp <= 0x206f) || cp == 0x3164 ||
|
||||
(cp >= 0xfe00 && cp <= 0xfe0d) || cp == 0xfeff || cp == 0xffa0 || (cp >= 0xfff9 && cp <= 0xfffb) ||
|
||||
(cp >= 0xe0000 && cp <= 0xe007f);
|
||||
}
|
||||
|
||||
// space characters that collapse to one U+0020
|
||||
function is_space(cp) {
|
||||
return cp == 0x20 || cp == 0xa0 || cp == 0x1680 || (cp >= 0x2000 && cp <= 0x200a) ||
|
||||
cp == 0x202f || cp == 0x205f || cp == 0x3000;
|
||||
}
|
||||
|
||||
// names.js validateAlias: -> { ok: true, value } ('' clears) or { ok: false, error }
|
||||
function validate_name(name) {
|
||||
if (name == null)
|
||||
name = '';
|
||||
|
||||
if (type(name) != 'string')
|
||||
return { ok: false, error: 'invalid-name' };
|
||||
|
||||
let cps = decode(name);
|
||||
|
||||
if (cps == null)
|
||||
return { ok: false, error: 'invalid-name' };
|
||||
|
||||
let units = 0;
|
||||
|
||||
for (let c in cps)
|
||||
units += (c[0] > 0xffff) ? 2 : 1;
|
||||
|
||||
if (units > NAME_MAX_UNITS)
|
||||
return { ok: false, error: 'name-too-long' };
|
||||
|
||||
for (let c in cps)
|
||||
if (forbidden(c[0]))
|
||||
return { ok: false, error: 'invalid-name' };
|
||||
|
||||
// collapse runs of spaces, trim both ends
|
||||
let out = '', count = 0, pending = false;
|
||||
|
||||
for (let c in cps) {
|
||||
if (is_space(c[0])) {
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pending && count) {
|
||||
out += ' ';
|
||||
count++;
|
||||
}
|
||||
|
||||
pending = false;
|
||||
out += substr(name, c[1], c[2]);
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > NAME_MAX)
|
||||
return { ok: false, error: 'name-too-long' };
|
||||
|
||||
return { ok: true, value: out };
|
||||
}
|
||||
|
||||
function scalar(v) {
|
||||
return (type(v) in [ 'string', 'int', 'double', 'bool' ]) ? v : null;
|
||||
}
|
||||
|
||||
function short_string(v, max) {
|
||||
return (type(v) == 'string' && length(v) <= max) ? v : null;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- wireless */
|
||||
|
||||
// the encryption mode ('sae-mixed+ccmp', 'psk2', 'none', ...): its leading
|
||||
// run of mode characters and nothing after it
|
||||
function encryption(v) {
|
||||
let m = (type(v) == 'string') ? match(v, /^[A-Za-z0-9+_-]{1,40}/) : null;
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
function reduce_iface(i) {
|
||||
let c = (type(i.config) == 'object') ? i.config : {};
|
||||
let networks = [];
|
||||
|
||||
for (let n in ((type(c.network) == 'array') ? c.network : [ c.network ]))
|
||||
if (type(n) == 'string' && length(n) <= 64)
|
||||
push(networks, n);
|
||||
|
||||
return {
|
||||
ifname: short_string(i.ifname, 64),
|
||||
section: short_string(i.section, 64),
|
||||
config: {
|
||||
ssid: short_string(c.ssid, 128),
|
||||
mode: short_string(c.mode, 32),
|
||||
hidden: scalar(c.hidden),
|
||||
encryption: encryption(c.encryption),
|
||||
ieee80211w: scalar(c.ieee80211w),
|
||||
ieee80211r: scalar(c.ieee80211r),
|
||||
ieee80211k: scalar(c.ieee80211k),
|
||||
ieee80211v: scalar(c.ieee80211v),
|
||||
network: networks,
|
||||
macaddr: norm_mac(c.macaddr)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function reduce_radio(r) {
|
||||
let c = (type(r.config) == 'object') ? r.config : {};
|
||||
let ifaces = [];
|
||||
|
||||
for (let i in ((type(r.interfaces) == 'array') ? r.interfaces : []))
|
||||
if (type(i) == 'object')
|
||||
push(ifaces, reduce_iface(i));
|
||||
|
||||
return {
|
||||
up: r.up === true,
|
||||
pending: r.pending === true,
|
||||
disabled: r.disabled === true,
|
||||
config: {
|
||||
band: short_string(c.band, 16),
|
||||
channel: scalar(c.channel),
|
||||
htmode: short_string(c.htmode, 16),
|
||||
country: short_string(c.country, 8),
|
||||
txpower: scalar(c.txpower),
|
||||
disabled: scalar(c.disabled)
|
||||
},
|
||||
interfaces: ifaces
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- set_alias */
|
||||
|
||||
function set_alias(args) {
|
||||
let mac = norm_mac(args.mac);
|
||||
|
||||
if (!mac || is_multicast(mac))
|
||||
return { ok: false, error: 'invalid-mac' };
|
||||
|
||||
let v = validate_name(args.name);
|
||||
|
||||
if (!v.ok)
|
||||
return v;
|
||||
|
||||
let icon = args.icon ?? '';
|
||||
|
||||
if (icon != '' && !(icon in ICONS))
|
||||
return { ok: false, error: 'invalid-icon' };
|
||||
|
||||
let uci = cursor();
|
||||
let mine = [], total = 0;
|
||||
|
||||
uci.load(CONFIG);
|
||||
uci.foreach(CONFIG, 'client', (s) => {
|
||||
total++;
|
||||
|
||||
if (norm_mac(s.mac) == mac)
|
||||
push(mine, s['.name']);
|
||||
});
|
||||
|
||||
let op;
|
||||
|
||||
if (v.value == '') {
|
||||
if (!length(mine))
|
||||
return { ok: true, op: 'none', mac };
|
||||
|
||||
for (let sid in mine)
|
||||
uci.delete(CONFIG, sid);
|
||||
|
||||
op = 'delete';
|
||||
}
|
||||
else if (length(mine)) {
|
||||
// one section per MAC: update the first, drop duplicates
|
||||
uci.set(CONFIG, mine[0], 'mac', mac);
|
||||
uci.set(CONFIG, mine[0], 'name', v.value);
|
||||
|
||||
if (icon != '')
|
||||
uci.set(CONFIG, mine[0], 'icon', icon);
|
||||
else
|
||||
uci.delete(CONFIG, mine[0], 'icon');
|
||||
|
||||
for (let sid in slice(mine, 1))
|
||||
uci.delete(CONFIG, sid);
|
||||
|
||||
op = 'set';
|
||||
}
|
||||
else {
|
||||
if (total >= MAX_ALIASES)
|
||||
return { ok: false, error: 'too-many' };
|
||||
|
||||
let sid = uci.add(CONFIG, 'client');
|
||||
|
||||
if (!sid)
|
||||
return { ok: false, error: 'failed' };
|
||||
|
||||
uci.set(CONFIG, sid, 'mac', mac);
|
||||
uci.set(CONFIG, sid, 'name', v.value);
|
||||
|
||||
if (icon != '')
|
||||
uci.set(CONFIG, sid, 'icon', icon);
|
||||
|
||||
op = 'add';
|
||||
}
|
||||
|
||||
if (!uci.commit(CONFIG))
|
||||
return { ok: false, error: 'failed' };
|
||||
|
||||
return { ok: true, op, mac };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ methods */
|
||||
|
||||
const methods = {
|
||||
wireless: {
|
||||
call: function() {
|
||||
let reply = connect()?.call('network.wireless', 'status');
|
||||
let out = {};
|
||||
|
||||
if (type(reply) != 'object')
|
||||
return out;
|
||||
|
||||
for (let name, r in reply)
|
||||
if (type(r) == 'object' && match(name, /^[A-Za-z0-9_.-]{1,64}$/))
|
||||
out[name] = reduce_radio(r);
|
||||
|
||||
return out;
|
||||
}
|
||||
},
|
||||
|
||||
set_alias: {
|
||||
args: { mac: 'mac', name: 'name', icon: 'icon' },
|
||||
call: function(request) {
|
||||
return set_alias(request.args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.vantage': methods };
|
||||
Reference in new issue
Block a user