🍉 Sync 2026-11-06 05:49:58
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-11-06 05:49:58 +08:00
1 parent f62cb2dfd1
commit 308b747f8f
12 files changed
+1512 -980

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-vantage
PKG_VERSION:=1.0.15
PKG_RELEASE:=25
PKG_RELEASE:=26
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage dashboard
@@ -110,6 +110,7 @@
--vt-b60: var(--vt-text-2);
--vt-band-ink: 60%; /* band colour in a chip's text: 4.5:1 on light surfaces */
--vt-series-up: var(--vt-chart-1);
--vt-tunnel: var(--vt-chart-4); /* a VPN's pipe on the line: apart from the accent, which the packets in it keep */
/* throughput directions: download keeps the uplink colour, upload
takes one that no band uses */
--vt-dir-dn: var(--vt-chart-1);
@@ -818,6 +819,44 @@ h2, h3{
.vt-app .vt-link-up .vt-ico { color: var(--vt-text-3); }
.vt-app .vt-link-meta { font-size: var(--vt-fs-micro); color: var(--vt-text-3); font-family: var(--vt-mono); white-space: nowrap; margin-top: 2px;
padding: 0 6px; border-radius: 6px; background: var(--vt-surface); }
/* a full-tunnel VPN over the main line: its wire drawn as a pipe in its
own colour around the traffic, the tunnel named under the line's rates */
.vt-app .vt-wire-tunnel { stroke-width: 9; stroke-linecap: round; stroke: color-mix(in oklab, var(--vt-tunnel) 24%, transparent); }
.vt-app .vt-wire-tunnel.vt-wire-on { stroke: color-mix(in oklab, var(--vt-tunnel) 36%, transparent); }
.vt-app .vt-link-tunnel {
display: inline-flex; align-items: center; gap: 4px; max-width: 100%; margin-top: 4px; padding: 1px 6px;
border: 1px solid color-mix(in oklab, var(--vt-tunnel) 60%, var(--vt-line)); border-radius: 6px;
background: var(--vt-surface); color: var(--vt-text-2); font-size: var(--vt-fs-micro); white-space: nowrap;
}
.vt-app .vt-link-tunnel[hidden] { display: none; }
.vt-app .vt-link-tunnel .vt-ico { width: 11px; height: 11px; flex: none; color: var(--vt-tunnel); stroke-width: 2; }
.vt-app .vt-link-tunnel-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-weight: var(--vt-fw-semibold); }
.vt-app .vt-link-tunnel-proto { flex: none; color: var(--vt-text-3); text-transform: uppercase; letter-spacing: .04em; }
.vt-app .vt-link-tunnel-proto:empty { display: none; }
/* more than one line to the internet (failover, mwan3): the main line,
its rates and the device in one row as with one, the other lines
under the main one, each wired into the device's bottom edge
(wires.line). The branches span every row; the rows above and below
take what a tall branch column leaves, so the lines stay together. */
.vt-app .vt-path-lines { grid-template-rows: 1fr auto auto 1fr; }
.vt-app .vt-path-lines > .vt-node-gw { grid-area: 2 / 1; }
.vt-app .vt-path-lines > .vt-link-label { grid-area: 2 / 2; }
.vt-app .vt-path-lines > .vt-node-ap { grid-area: 2 / 3; }
.vt-app .vt-path-lines > .vt-branches { grid-area: 1 / 4 / -1 / 5; }
.vt-app .vt-lines { grid-area: 3 / 1; align-self: start; display: flex; flex-direction: column; gap: 12px; padding: 14px 0 6px; min-width: 0; }
.vt-app .vt-lines > .vt-node { align-self: stretch; }
.vt-app .vt-node-rates { flex-wrap: wrap; column-gap: .3em; row-gap: 0; }
.vt-app .vt-node-rates > span:empty { display: none; }
/* a line's name ellipsized; the tag goes under it when they don't fit */
.vt-app .vt-node-kicker-tag { flex-wrap: wrap; row-gap: 2px; }
.vt-app .vt-node-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* the main line among others: the default route the kernel takes */
.vt-app .vt-node-tag {
display: inline-flex; align-items: center; height: 16px; padding: 0 5px; border-radius: 4px;
text-transform: none; letter-spacing: 0; font-weight: var(--vt-fw-medium);
background: var(--vt-accent-soft); color: var(--vt-accent-text);
}
/* the branches spread over the height the tile gets from its row, so a
tall neighbour leaves no empty band above or below the diagram */
@@ -836,6 +875,13 @@ h2, h3{
.vt-app .vt-branch > .vt-node { justify-self: start; min-width: 200px; }
.vt-app .vt-branch-ssids { padding-left: 34px; }
.vt-app .vt-branch-ssids .vt-node { max-width: none; align-self: stretch; }
/* one column: the main line, its rates, the other lines set in (their
wires join the main line's drop into the device), the device */
.vt-app .vt-path-lines { grid-template-rows: none; }
.vt-app .vt-path-lines > .vt-node-gw, .vt-app .vt-path-lines > .vt-link-label, .vt-app .vt-path-lines > .vt-node-ap,
.vt-app .vt-path-lines > .vt-branches, .vt-app .vt-lines { grid-area: auto; }
.vt-app .vt-lines { gap: 8px; padding: 0 0 0 34px; }
.vt-app .vt-lines > .vt-node { align-self: flex-start; min-width: 200px; max-width: 100%; }
}
/* ------------------------------------------------------------ 6. system */
@@ -23,6 +23,159 @@ function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o,
function obj(v) { return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {}; }
function arr(v) { return Array.isArray(v) ? v : []; }
/* ------------------------------------------------------ default routes */
/* lines to the internet the path and the chart show beside the main one;
any further one stays a network */
var MAX_LINES = 4;
/* netifd protocols, and kernel device types, of tunnels that ride over
another line: a full-tunnel VPN is not a second way out. PPTP and L2TP
are left out: some ISPs give their access through them. */
var TUNNEL = /^(wireguard|openvpn|openconnect|openfortivpn|vpnc|sstp|gre|gretap|grev6|grev6tap|vti|vtiv6|xfrm|ipip|6in4|6to4|6rd|vxlan|vxlan6)$/;
var TUNNEL_DEV = /^(wireguard|tun|tap|ipip|ip6tnl|sit|gre|gretap|ip6gre|ip6gretap|vti|vti6|xfrm|vxlan)$/;
/* netifd lists a route's metric only when the route sets one; the
interface's metric applies otherwise. The kernel stores an IPv6
metric of 0 as 1024. */
function metric(i, r) {
var m = r.metric != null ? +r.metric : +i.metric;
m = isFinite(m) ? m : 0;
return r.target === '::' && m === 0 ? 1024 : m;
}
/* a default in another table (policy routing: a VPN's own table) is
not the one ordinary traffic takes */
function mainTable(i, r) {
var t = r.table != null ? r.table : i[r.target === '::' ? 'ip6table' : 'ip4table'];
return t == null || t === '' || t === 'main' || +t === 254;
}
/* the interface's best default route in the main table, IPv4 first */
function defRoute(i) {
return arr(i.route).filter(function(r) {
return r && +r.mask === 0 && (r.target === '0.0.0.0' || r.target === '::') && mainTable(i, r);
}).sort(function(a, b) {
return (a.target === '::') - (b.target === '::') || metric(i, a) - metric(i, b);
})[0] || null;
}
function wanRank(i) {
return /^wan$/.test(i.interface) ? 0 : /^wan6$/.test(i.interface) ? 1 : /wan/i.test(i.interface) ? 2 : 3;
}
function upIfaces(ifaces) {
return arr(ifaces).filter(function(i) {
return i && i.up && i.interface !== 'loopback' && (i.l3_device || i.device);
});
}
function isTunnel(i, devs) {
var d = devs[i.l3_device || i.device];
return TUNNEL.test(i.proto || '') || !!(d && (TUNNEL_DEV.test(d.devtype || '') || d.type === 'tunnel'));
}
/* the interfaces with a default route, the line first: real lines
before tunnels (a full-tunnel VPN rides over a line, see ridingTunnel),
IPv4 before IPv6 (a softwire's IPv4 route among the IPv4 ones), then
the lowest metric, wan/wan6/*wan* on a tie, then the order netifd lists
them */
function rankRouted(up, devs) {
return up.map(function(i, n) {
var r = defRoute(i);
/* a softwire's device is a tunnel's (ip6tnl), but it carries the line's IPv4, not a VPN */
var sw = SOFTWIRE.test(i.proto || '');
return r ? { i: i, tunnel: !sw && isTunnel(i, devs), softwire: sw, family: r.target === '::' ? 1 : 0,
metric: metric(i, r), wan: wanRank(i), n: n } : null;
}).filter(Boolean).sort(function(a, b) {
return a.tunnel - b.tunnel || a.family - b.family || a.metric - b.metric || a.wan - b.wan || a.n - b.n;
});
}
/* A full-tunnel VPN over the line: the tunnel holding the default route
that ordinary traffic takes, IPv4's whenever there is one, else IPv6's
(an IPv4 VPN over an IPv6-only line rides it too). `kernel` is the
kernel's own answer (luci.vantage routes: [ { family, dev, metric } ],
the route it takes to the internet, at most one a family), which
decides: of two routes with the same metric the kernel keeps the one
added last, which netifd's list cannot tell, and a VPN daemon may add
its own (OpenVPN's tun0) that netifd never lists. Over several next
hops (dev null), no tunnel. Without it, only a tunnel whose metric is
below that family's best line's, or the family's only default.
-> { i, metric } of the tunnel, or null */
function ridingTunnel(ifaces, ranked, devs, kernel) {
if (Array.isArray(kernel)) {
var pick = function(fam) { return kernel.filter(function(x) { return x && x.family === fam && finite(x.metric); })[0]; };
var k = pick(4) || pick(6);
if (!k || typeof k.dev !== 'string') return null;
var i = upIfaces(ifaces).filter(function(x) {
return (x.l3_device || x.device) === k.dev && !SOFTWIRE.test(x.proto || '') && isTunnel(x, devs);
})[0];
return i ? { i: i, metric: k.metric } : null;
}
var fam = ranked.some(function(r) { return r.family === 0; }) ? 0 : 1;
var of = ranked.filter(function(r) { return r.family === fam; });
var t = of.filter(function(r) { return r.tunnel; })[0], line = of.filter(function(r) { return !r.tunnel; })[0];
return t && (!line || t.metric < line.metric) ? { i: t.i, metric: t.metric } : null;
}
/* The lines to the internet, the main one first: { r, members, metric }
with the interfaces each is made of. A softwire (DS-Lite, MAP-E,
464XLAT) that wins carries IPv4 over the IPv6 line, so that line is
the main one, at the softwire's metric; it is never a line of its own,
nor a tunnel unless nothing else has a default. One line per L3
device: wan6 on wan's device (or an @wan alias) is part of wan's, and
so is an IPv6 interface on the device a PPPoE line runs over (its
counters hold the PPP session's). Two IPv4 lines that only share a
parent device (two PPPoE sessions on eth0) stay two. The main line
carries the full-tunnel VPN over it, if any (tunnel; ridingTunnel). */
function groupLines(ifaces, devs, kernel) {
var ranked = rankRouted(upIfaces(ifaces), devs), main = ranked[0], groups = [];
if (!main) return groups;
var at = main.metric, rider = main.tunnel ? null : ridingTunnel(ifaces, ranked, devs, kernel);
if (main.softwire)
main = ranked.filter(function(r) { return !r.tunnel && !r.softwire && r.family === 1; })[0] || main;
/* l3: the members' L3 devices; dev4: the devices their IPv4 runs over */
function add(g, r) {
g.members.push(r.i);
g.l3.push(r.i.l3_device || r.i.device);
if (r.family === 0 && typeof r.i.device === 'string') g.dev4.push(r.i.device);
}
function start(r, m) {
var g = { r: r, members: [], metric: m, l3: [], dev4: [] };
add(g, r);
groups.push(g);
}
start(main, at);
groups[0].tunnel = rider;
ranked.forEach(function(r) {
if (r === main || r.softwire || r.tunnel) return;
var l3 = r.i.l3_device || r.i.device;
var g = groups.filter(function(x) { return x.l3.indexOf(l3) >= 0 || (r.family === 1 && x.dev4.indexOf(l3) >= 0); })[0];
if (g) add(g, r);
else start(r, r.metric);
});
return groups;
}
function cidrs(list) {
return arr(list).map(function(a) { return a && a.address ? a.address + '/' + a.mask : null; }).filter(Boolean);
}
/* a line's other interfaces add their addresses and DNS to it (wan6's
IPv6 on wan's line) */
function absorb(x, g) {
function more(list, add) { add.forEach(function(v) { if (list.indexOf(v) < 0) list.push(v); }); }
g.members.slice(1).forEach(function(i) {
more(x.ipv4, cidrs(i['ipv4-address']));
more(x.ipv6, cidrs(i['ipv6-address']));
more(x.dns, arr(i['dns-server']).filter(function(v) { return typeof v === 'string'; }));
});
x.metric = g.metric;
x.ifaces = g.members.map(function(i) { return i.interface; });
return x;
}
/* Two lines on DSA ports of one switch: the conduit carries both (and
the other ports), so each keeps its own port's counters. */
function ownConduits(lines) {
var shared = lines.map(function(x, k) {
return x.statsDev !== x.dev && lines.some(function(y, j) { return j !== k && (y.statsDev === x.statsDev || y.dev === x.statsDev); });
});
lines.forEach(function(x, k) { if (shared[k]) x.statsDev = x.dev; });
return lines;
}
return baseclass.extend({
/* the highest rate a sample may carry; above it a counter was reset */
MAX_BPS: MAX_BPS,
@@ -213,67 +366,70 @@ return baseclass.extend({
return v + (sp.duplex === 'full' ? ' · ' + _('full duplex') : sp.duplex === 'half' ? ' · ' + _('half duplex') : '');
},
/* lines beside the main one that the model lists (m.uplinks) */
MAX_LINES: MAX_LINES,
/* Pick the interface carrying traffic in/out of this device, and the
physical device its counters live on. Prefer interfaces with a default
route in the main table, as the kernel picks among them: IPv4 before IPv6, then the
lowest metric (multi-WAN: the backup uplink has the higher one), with
route in the main table, as the kernel picks among them (groupLines):
real lines before tunnels, IPv4 before IPv6, then the lowest metric
(multi-WAN: the backup uplink has the higher one), with
wan/wan6/*wan* priority only on a tie. A softwire (DS-Lite, MAP-E,
464XLAT) carries IPv4 over the IPv6 uplink, so it ranks after IPv6:
the uplink shown is the line, not the tunnel. Fall back to WAN names, then
464XLAT) carries IPv4 over the IPv6 uplink: the uplink shown is
the line, not the tunnel. Fall back to WAN names, then
the first up interface. A bridge, or a VLAN on one (br-lan.10), is
followed to its wired member (wireless members skipped): the port
given as `via` (where the bridge learned the gateway), else one with
a link, netifd 'ethernet' preferred. `port` is the member known to lead to the
gateway: `via`, or the bridge's only wired member; null when it has
several and the gateway's port is unknown. DSA with hardware
offload uses the conduit's counters. */
resolveUplink: function(ifaces, devs, wirelessNames, via) {
var self = this;
ifaces = arr(ifaces);
offload uses the conduit's counters. `kernel`: the kernel's
default routes (luci.vantage routes), or null; with a full-tunnel
VPN over the line, `tunnel` names it (ridingTunnel). */
resolveUplink: function(ifaces, devs, wirelessNames, via, kernel) {
devs = obj(devs);
var up = ifaces.filter(function(i) {
return i && i.up && i.interface !== 'loopback' && (i.l3_device || i.device);
});
function named(list, re) { return list.filter(function(i) { return re.test(i.interface); })[0]; }
function preferredWan(list) {
return named(list, /^wan$/) || named(list, /^wan6$/) || named(list, /wan/i);
var g = groupLines(ifaces, devs, kernel)[0], up = upIfaces(ifaces);
if (g) {
var x = absorb(this.line(g.r.i, devs, wirelessNames, via), g), t = g.tunnel && g.tunnel.i;
x.tunnel = t ? { iface: t.interface, proto: t.proto && t.proto !== 'none' ? t.proto : null, dev: t.l3_device || t.device, metric: g.tunnel.metric } : null;
return x;
}
function wanRank(i) {
return /^wan$/.test(i.interface) ? 0 : /^wan6$/.test(i.interface) ? 1 : /wan/i.test(i.interface) ? 2 : 3;
}
/* netifd lists a route's metric only when the route sets one; the
interface's metric applies otherwise. The kernel stores an IPv6
metric of 0 as 1024. */
function metric(i, r) {
var m = r.metric != null ? +r.metric : +i.metric;
m = isFinite(m) ? m : 0;
return r.target === '::' && m === 0 ? 1024 : m;
}
/* a default in another table (policy routing: a VPN's own table) is
not the one ordinary traffic takes */
function mainTable(i, r) {
var t = r.table != null ? r.table : i[r.target === '::' ? 'ip6table' : 'ip4table'];
return t == null || t === '' || t === 'main' || +t === 254;
}
function defRoute(i) {
return arr(i.route).filter(function(r) {
return r && +r.mask === 0 && (r.target === '0.0.0.0' || r.target === '::') && mainTable(i, r);
}).sort(function(a, b) {
return (a.target === '::') - (b.target === '::') || metric(i, a) - metric(i, b);
})[0] || null;
}
function named(re) { return up.filter(function(i) { return re.test(i.interface); })[0]; }
/* An AP may retain an up, unmanaged WAN port without any routes. */
var routed = up.map(function(i, n) {
var r = defRoute(i);
return r ? { i: i, family: SOFTWIRE.test(i.proto) ? 2 : r.target === '::' ? 1 : 0, metric: metric(i, r), wan: wanRank(i), n: n } : null;
}).filter(Boolean).sort(function(a, b) {
return a.family - b.family || a.metric - b.metric || a.wan - b.wan || a.n - b.n;
});
var pick = routed.length ? routed[0].i : preferredWan(up) || up[0];
if (!pick) return null;
var pick = named(/^wan$/) || named(/^wan6$/) || named(/wan/i) || up[0];
return pick ? this.line(pick, devs, wirelessNames, via) : null;
},
/* The other lines to the internet beside `main` (resolveUplink's):
every other up interface with a default route in the main table
(failover by metric, mwan3), in the kernel's order, as groupLines
makes them: a softwire or a tunnel (WireGuard, OpenVPN, GRE,
6in4, ...) rides over another line and is not one of its own; a
default in another table (policy routing) is not ordinary
traffic's. A WAN that is down has no route: it stays a network
until it comes up. On a bridge, `viaOf(next hop)` names the port
the bridge learned that gateway on; without it a bridge with
several wired ports keeps its own counters.
-> [ line, ... ] as resolveUplink's, without main */
extraUplinks: function(ifaces, devs, wirelessNames, main, viaOf) {
var self = this;
if (!main) return [];
devs = obj(devs);
return groupLines(ifaces, devs).slice(1).filter(function(g) {
return g.l3.indexOf(main.l3dev) < 0 && !g.members.some(function(i) { return i.interface === main.iface; });
}).map(function(g) {
var r = defRoute(g.r.i), hop = r && r.nexthop;
return absorb(self.line(g.r.i, devs, wirelessNames, hop && typeof viaOf === 'function' ? viaOf(hop) : null, true), g);
});
},
/* one line: the interface, the device its counters live on (see
resolveUplink), its addresses and its default route. `own`: on a
bridge with several wired ports and the gateway's port unknown,
the bridge's (or its VLAN's) own counters, not a guessed port's. */
line: function(pick, devs, wirelessNames, via, own) {
var self = this;
devs = obj(devs);
var l3 = pick.l3_device || pick.device, dev = l3, d = devs[dev], port = dev;
var isBridge = function(x) { return !!x && (x.type === 'bridge' || x.devtype === 'bridge') && Array.isArray(x['bridge-members']); };
var vlanOf = /^(.+)\.\d{1,4}$/.exec(dev);
@@ -284,16 +440,19 @@ return baseclass.extend({
var eth = wired.filter(function(m) { return devs[m] && devs[m].devtype === 'ethernet'; });
var lit = function(m) { return devs[m] && devs[m].carrier === true; };
var known = typeof via === 'string' && wired.indexOf(via) >= 0 ? via : null;
var next = known || eth.filter(lit)[0] || wired.filter(lit)[0] || eth[0] || wired[0] || members[0];
if (next) { dev = next; d = devs[dev] || null; }
port = known || (wired.length === 1 ? wired[0] : null);
if (!known && own && wired.length > 1) port = null;
else {
var next = known || eth.filter(lit)[0] || wired.filter(lit)[0] || eth[0] || wired[0] || members[0];
if (next) { dev = next; d = devs[dev] || null; }
port = known || (wired.length === 1 ? wired[0] : 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), via = route && route.nexthop;
var route = defRoute(pick), hop = route && route.nexthop;
return {
iface: pick.interface,
l3dev: l3,
@@ -306,22 +465,28 @@ return baseclass.extend({
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: via && via !== '0.0.0.0' && via !== '::' ? via : null,
ipv4: cidrs(pick['ipv4-address']),
ipv6: cidrs(pick['ipv6-address']),
gateway: hop && hop !== '0.0.0.0' && hop !== '::' ? hop : null,
/* a tunnel's default route has no next hop: routed, without a gateway address */
defaultRoute: !!route,
metric: route ? metric(pick, route) : null,
dns: arr(pick['dns-server']).filter(function(s) { return typeof s === 'string'; }),
/* the interfaces the line is made of (wan, wan6) */
ifaces: [ pick.interface ],
stats: d && d.statistics ? d.statistics : null
};
},
/* The device's other networks (lan, guest, a VPN, ...): every
interface except loopback and the uplink (wan6 shares wan's device).
One entry per device, by interface name.
interface except loopback and the uplinks shown (a line or a list
of them, with the interfaces each is made of; wan6 shares wan's
device) and the full-tunnel VPN drawn on the main one. One entry
per device, by interface name.
-> [ { name, proto, dev, up, ipv4 } ] */
networks: function(ifaces, uplink) {
networks: function(ifaces, uplinks) {
var seen = Object.create(null);
uplinks = arr(Array.isArray(uplinks) ? uplinks : [ uplinks ]).filter(Boolean);
return arr(ifaces).filter(function(i) {
return i && typeof i.interface === 'string' && i.interface !== 'loopback';
}).sort(function(a, b) {
@@ -337,7 +502,10 @@ return baseclass.extend({
ipv4: a4 ? a4.address + '/' + a4.mask : null
};
}).filter(function(n) {
if (uplink && (n.name === uplink.iface || (n.dev && n.dev === uplink.l3dev))) return false;
if (uplinks.some(function(u) {
return n.name === u.iface || arr(u.ifaces).indexOf(n.name) >= 0 || (n.dev && n.dev === u.l3dev) ||
!!(u.tunnel && (n.name === u.tunnel.iface || (n.dev && n.dev === u.tunnel.dev)));
})) return false;
if (n.dev && seen[n.dev]) return false;
if (n.dev) seen[n.dev] = true;
return true;
@@ -407,6 +575,7 @@ return baseclass.extend({
assoc: { ifname: [ station ] }, hapd: { ifname: { clients } },
hapdStatus: { ifname: status }, hints, aliases, rdns, mdns,
fdb: [ luci.vantage wired host ] (-> wiredHosts),
routes: [ luci.vantage routes route ] or null,
vendor: fn(mac) -> name|null } */
build: function(raw) {
raw = raw || {};
@@ -491,7 +660,8 @@ return baseclass.extend({
radios.push(radio);
});
var uplink = this.resolveUplink(raw.ifaces, devs, wlNames);
var kernel = Array.isArray(raw.routes) ? raw.routes : null;
var uplink = this.resolveUplink(raw.ifaces, devs, wlNames, null, kernel);
var subnets = uplink ? uplink.ipv4 : [];
function hintOf(mac) { return obj(hints[mac.toUpperCase()] || hints[mac]); }
@@ -517,17 +687,25 @@ return baseclass.extend({
if (!prev) wiredMacs.push(mac);
fdb[mac] = { port: h.port, bridge: typeof h.bridge === 'string' ? h.bridge : null, vlan: finite(h.vlan) ? h.vlan : null, seen: seen };
});
/* the gateway, by the address the host hints give it; on a bridge
with several ports the uplink is the port it was learned on */
var gwMac = null;
if (uplink && uplink.gateway) Object.keys(hints).forEach(function(k) {
var mac = names.normMac(k);
if (mac && arr(obj(hints[k]).ipaddrs).concat(arr(obj(hints[k]).ip6addrs)).indexOf(uplink.gateway) >= 0) gwMac = mac;
});
/* a gateway, by the address the host hints give it; on a bridge
with several ports a line is the port it was learned on */
function macOf(addr) {
var found = null;
if (addr) Object.keys(hints).forEach(function(k) {
var mac = names.normMac(k);
if (mac && arr(obj(hints[k]).ipaddrs).concat(arr(obj(hints[k]).ip6addrs)).indexOf(addr) >= 0) found = mac;
});
return found;
}
var gwMac = uplink ? macOf(uplink.gateway) : null;
if (uplink && gwMac && fdb[gwMac] && uplink.port !== fdb[gwMac].port) {
var via = this.resolveUplink(raw.ifaces, devs, wlNames, fdb[gwMac].port);
var via = this.resolveUplink(raw.ifaces, devs, wlNames, fdb[gwMac].port, kernel);
if (via && via.port === fdb[gwMac].port) uplink = via;
}
var uplinks = uplink ? ownConduits([ uplink ].concat(this.extraUplinks(raw.ifaces, devs, wlNames, uplink, function(hop) {
var mac = macOf(hop);
return mac && fdb[mac] ? fdb[mac].port : null;
}).slice(0, MAX_LINES))) : [];
/* the network on each bridge (or bridge VLAN: br-lan.10), and its
IPv4 subnets for the hosts' addresses */
var lans = Object.create(null);
@@ -670,7 +848,10 @@ return baseclass.extend({
storage: [ this.mount(info.root, _('Root (overlay)')), this.mount(info.tmp, _('Temporary (RAM)')) ].filter(Boolean)
},
uplink: uplink,
networks: this.networks(raw.ifaces, uplink),
/* the main line first, then up to MAX_LINES others to the
internet; networks leaves out only these */
uplinks: uplinks,
networks: this.networks(raw.ifaces, uplinks),
radios: radios,
ssids: ssids,
clients: clients,
@@ -11,6 +11,7 @@
A box is { l, r, t, b, cy } relative to the path element. */
var STEPS = 32; /* points per curve before resampling */
var LANE = 10; /* px between the main line's drop and the other lines', stacked */
var GAP = 16; /* px between dots, as the old dash pattern */
var STRIDE = 32; /* px a dot travels per period */
var PERIOD = { 1: 1600, 2: 1050, 3: 600 }; /* ms per STRIDE by speed */
@@ -50,6 +51,48 @@ return baseclass.extend({
return { d: 'M' + f1(x1) + ' ' + f1(y1) + 'V' + f1(y2 - rad) + 'Q' + f1(x1) + ' ' + f1(y2) + ' ' + f1(x1 + rad) + ' ' + f1(y2) + 'H' + f1(x2), pts: pts };
},
/* from another line to the internet (a, the k-th beside the main
one) into the device (b), clear of the main line's rates: side by
side and below the device, along a's middle and up into b's bottom
edge, each further line 14 px further in, so the wires nest
without crossing; stacked (a phone), from a's left edge down a lane
of their own beside the main line's drop (LANE px to its right, so
no line runs inside another's wire or a tunnel's pipe) into b's top
edge. Otherwise as curve(). */
line: function(a, b, k) {
var x1, y1, x2, y2, rad, i, s, v, pts = [];
if (b.l >= a.r - 2 && a.cy > b.b) {
x1 = a.r; y1 = a.cy; y2 = b.b;
x2 = Math.min(b.l + 18 + 14 * (k || 0), (b.l + b.r) / 2);
rad = Math.min(8, y1 - y2, x2 - x1);
if (rad > 0) {
pts.push([ x1, y1 ]);
for (i = 0; i <= 8; i++) {
/* quadratic corner from (x2 - rad, y1) via (x2, y1) to (x2, y1 - rad) */
s = i / 8; v = 1 - s;
pts.push([ v * v * (x2 - rad) + 2 * v * s * x2 + s * s * x2, v * v * y1 + 2 * v * s * y1 + s * s * (y1 - rad) ]);
}
pts.push([ x2, y2 ]);
return { d: 'M' + f1(x1) + ' ' + f1(y1) + 'H' + f1(x2 - rad) + 'Q' + f1(x2) + ' ' + f1(y1) + ' ' + f1(x2) + ' ' + f1(y1 - rad) + 'V' + f1(y2), pts: pts };
}
}
if (b.l < a.r - 2 && a.b <= b.t) {
x1 = a.l; y1 = a.cy; x2 = b.l + 18 + LANE; y2 = b.t;
rad = Math.min(8, x1 - x2, y2 - y1);
if (rad > 0) {
pts.push([ x1, y1 ]);
for (i = 0; i <= 8; i++) {
/* quadratic corner from (x2 + rad, y1) via (x2, y1) to (x2, y1 + rad) */
s = i / 8; v = 1 - s;
pts.push([ v * v * (x2 + rad) + 2 * v * s * x2 + s * s * x2, v * v * y1 + 2 * v * s * y1 + s * s * (y1 + rad) ]);
}
pts.push([ x2, y2 ]);
return { d: 'M' + f1(x1) + ' ' + f1(y1) + 'H' + f1(x2 + rad) + 'Q' + f1(x2) + ' ' + f1(y1) + ' ' + f1(x2) + ' ' + f1(y1 + rad) + 'V' + f1(y2), pts: pts };
}
}
return this.curve(a, b);
},
/* points at equal distances along a polyline, about `step` px apart,
each with the direction of travel in degrees (unwrapped, so the
keyframes never turn the long way round); { pts: [[x, y, deg]], len } */
@@ -47,6 +47,9 @@ var callThermal = rpc.declare({ object: 'luci.vantage', method: 'thermal', expec
/* { hosts, crowded, switches }: crowded when a bridge holds too many
entries to list; switches, the swconfig switch ports behind a port */
var callWired = rpc.declare({ object: 'luci.vantage', method: 'wired', expect: { '': {} } });
/* the route the kernel takes to the internet: which one it kept of two
with one metric; { routes: null } when it could not be asked */
var callRoutes = rpc.declare({ object: 'luci.vantage', method: 'routes', expect: { '': {} } });
/* LuCI's own (its switch page's): link and speed of a switch's ports, in
result. The whole reply, and a failed call rejected: a default [] would
read as every port down */
@@ -296,6 +299,12 @@ var CHECK_ICONS = { uplink: 'port', cpu: 'cpu', memory: 'memory', radios: 'wifi'
function txt(el, s) { s = (s == null ? '' : String(s)); if (el.textContent !== s) el.textContent = s; return el; }
function setClass(el, c) { if (el.getAttribute('class') !== c) el.setAttribute('class', c); }
function setTitle(el, v) { if (el.getAttribute('title') !== v) el.setAttribute('title', v); }
/* the throughput history of a line to the internet, the main one too,
by its interface: a failover starts the new main line's own history
instead of continuing the old one's. ':' keeps it apart from a radio's
(a uci section name). */
function lineKey(x) { return 'up:' + x.iface; }
function histMax(key) { return key.indexOf('up:') === 0 ? HIST_FAST : HIST_MAX; }
/* an element's own tag and attributes, or a text node's text */
function nodeSig(n) {
@@ -616,7 +625,9 @@ return view.extend({
L.resolveDefault(callWifi(), null),
L.resolveDefault(callHints(), {}),
L.resolveDefault(callThermal(), []),
L.resolveDefault(callWired(), {})
L.resolveDefault(callWired(), {}),
/* null when the call fails (an older plugin): netifd's metrics decide */
L.resolveDefault(callRoutes(), null)
]).then(function(r) {
var wifiDevs = (r[4] && typeof r[4] === 'object') ? r[4] : null;
var wiredReply = (r[7] && typeof r[7] === 'object') ? r[7] : {};
@@ -669,6 +680,9 @@ return view.extend({
Object.keys(swState).forEach(function(n) { switchState[n] = swState[n].ports; });
return { info: r[0] || {}, stat: r[1] || '', ifaces: r[2] || [], devs: r[3] || {}, wifi: wifiDevs, hints: r[5] || {}, thermal: r[6] || [],
fdb: Array.isArray(wiredReply.hosts) ? wiredReply.hosts : [], fdbCrowded: wiredReply.crowded === true, switches: switches, switchState: switchState,
/* no answer (an older plugin, a lookup not sent, a failed call)
keeps the last one: no tunnel blinking off */
routes: r[8] && Array.isArray(r[8].routes) ? (self.kernelRoutes = r[8].routes) : (self.kernelRoutes || null),
assoc: assoc, hapd: hapd, hapdStatus: hapdStatus, iwinfo: iwinfo, at: Date.now(), started: started };
});
});
@@ -682,7 +696,8 @@ return view.extend({
var net = Object.create(null);
if (stored.net && typeof stored.net === 'object')
Object.keys(stored.net).forEach(function(k) {
if (/^[A-Za-z0-9_.:-]{1,40}$/.test(k)) net[k] = series.valid(stored.net[k], 3, k === 'uplink' ? HIST_FAST : HIST_MAX, now, model.MAX_BPS);
/* 'uplink' (before #88) belongs to an interface nobody noted */
if (/^[A-Za-z0-9_.:-]{1,40}$/.test(k) && k !== 'uplink') net[k] = series.valid(stored.net[k], 3, histMax(k), now, model.MAX_BPS);
});
var clientHist = Object.create(null);
if (stored.clients && typeof stored.clients === 'object')
@@ -757,7 +772,7 @@ return view.extend({
var m = model.build({
board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo,
assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, fdb: raw.fdb,
switches: raw.switches, switchState: raw.switchState,
switches: raw.switches, switchState: raw.switchState, routes: raw.routes,
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
});
@@ -790,7 +805,16 @@ return view.extend({
st.uplinkRate = upRate || null;
/* throughput history (only for ticks that produced a rate) */
if (upRate && !upFast) this.pushNet('uplink', at, upRate);
if (upRate && !upFast) this.pushNet(lineKey(m.uplink), at, upRate);
/* the other lines to the internet: this poll's (the fast lane
samples the main one only) */
this.extraLines(m).forEach(function(x) { var r = st.devRates[x.statsDev]; if (r) self.pushNet(lineKey(x), at, r); });
/* a line gone for longer than the chart shows leaves no history */
var lineKeys = m.uplinks.map(lineKey);
Object.keys(st.hist.net).forEach(function(k) {
var h = st.hist.net[k];
if (/^up:/.test(k) && lineKeys.indexOf(k) < 0 && !(h.length && h[h.length - 1][0] > at - WINDOW_MS)) delete st.hist.net[k];
});
m.radios.forEach(function(r) { if (st.radioRates[r.id]) self.pushNet(r.id, at, st.radioRates[r.id]); });
/* station counters, per-station history */
@@ -863,7 +887,7 @@ return view.extend({
pushNet: function(key, at, r) {
var h = this.st.hist.net[key] || (this.st.hist.net[key] = []);
series.push(h, [ at, r.rx, r.tx ], WINDOW_MS, key === 'uplink' ? HIST_FAST : HIST_MAX);
series.push(h, [ at, r.rx, r.tx ], WINDOW_MS, histMax(key));
},
/* ------------------------------------------------------- fast lane */
@@ -901,7 +925,7 @@ return view.extend({
if (dev && s) counters[dev] = { rx: s.rx_bytes, tx: s.tx_bytes };
var dr = model.rates(st.fastPrev, counters, now);
st.fastPrev = dr.next;
if (dev && dr.rates[dev]) { st.uplinkRate = dr.rates[dev]; self.pushNet('uplink', at, st.uplinkRate); changed = true; }
if (dev && dr.rates[dev]) { st.uplinkRate = dr.rates[dev]; self.pushNet(lineKey(up), at, st.uplinkRate); changed = true; }
/* both answered: the full poll leaves CPU and the uplink to this lane */
if (stat && (!dev || s)) st.fastAt = now;
if (changed) self.paintFast();
@@ -972,7 +996,7 @@ return view.extend({
var m = model.build({
board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo,
assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, fdb: raw.fdb,
switches: raw.switches, switchState: raw.switchState,
switches: raw.switches, switchState: raw.switchState, routes: raw.routes,
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
});
@@ -1608,7 +1632,7 @@ return view.extend({
the card's bottom edge; CPU scaled to at least 20 % so a quiet
device still shows its shape */
case 'uplink':
return [ sparkline(st.hist.net.uplink || [], [ 1 ], 0, 'vt-spark-up', { min: 1000, quiet: true, bare: true }) ];
return [ sparkline((this.m && this.m.uplink && st.hist.net[lineKey(this.m.uplink)]) || [], [ 1 ], 0, 'vt-spark-up', { min: 1000, quiet: true, bare: true }) ];
case 'cpu':
return [ sparkline(st.hist.cpu || [], [ 1 ], 0, 'vt-spark-cpu', { min: 20, quiet: true, bare: true }) ];
case 'memory':
@@ -1698,26 +1722,38 @@ return view.extend({
/* the nodes the path shows: rebuilt only when this changes (a radio,
SSID or network comes or goes); a poll updates text in place */
heroKey: function() {
var m = this.m;
if (m.wired) return 'w\n' + m.networks.filter(function(n) { return n.up; }).slice(0, 8).map(function(n) { return n.name; }).join('\n');
var m = this.m, lines = '\nlines=' + this.extraLines().map(function(x) { return x.iface; }).join(',');
if (m.wired) return 'w\n' + m.networks.filter(function(n) { return n.up; }).slice(0, 8).map(function(n) { return n.name; }).join('\n') + lines;
return 'r\n' + m.radios.map(function(r) {
return r.id + '=' + m.ssids.filter(function(s) { return s.radio === r.id; }).map(function(s) { return s.id; }).join(',');
}).join('\n') + '\nwired=' + this.wiredPorts().join(',');
}).join('\n') + '\nwired=' + this.wiredPorts().join(',') + lines;
},
/* the lines to the internet beside the main one (failover, mwan3;
at most model.MAX_LINES) */
extraLines: function(m) {
m = m || this.m;
return ((m && m.uplinks) || []).slice(1);
},
/* the ports with wired hosts downstream of this device, at most 8:
hosts on the uplink's port are the way to the gateway, not clients
hosts on a line's port are the way to its gateway, not clients
(on a bridge with several ports whose gateway port is unknown, only
the gateway is left out) */
wiredPorts: function() {
var m = this.m, up = m.uplink, out = [];
var m = this.m, out = [], self = this;
(m.wiredHosts || []).forEach(function(c) {
if (c.gateway || (up && up.port && c.port === up.port)) return;
if (c.gateway || self.linePort(c.port)) return;
if (out.indexOf(c.port) < 0) out.push(c.port);
});
return out.sort().slice(0, 8);
},
/* the port is one a line to the internet leaves through */
linePort: function(port) {
return ((this.m && this.m.uplinks) || []).some(function(x) { return x.port && x.port === port; });
},
/* the Clients tile on its wired list, on one port's hosts or all */
showWired: function(port) {
this.ui.kind = 'wired';
@@ -1759,18 +1795,40 @@ return view.extend({
if (c !== self.el.wires && c !== self.el.flows) path.removeChild(c);
});
h.gwKicker = span('vt-node-kicker'); h.gwTitle = span('vt-node-title'); h.gwSub = span('vt-node-sub');
h.gw = E('button', { 'type': 'button', 'class': 'vt-node vt-node-gw', 'click': function() { self.openDrawer('uplink', 'uplink'); } }, [
/* more than one line to the internet: each by its interface, the
main one (the default route the kernel takes) marked */
var extra = this.extraLines();
h.gwKicker = span(extra.length ? 'vt-node-name' : 'vt-node-kicker'); h.gwTitle = span('vt-node-title'); h.gwSub = span('vt-node-sub');
h.gwTag = extra.length ? E('span', { 'class': 'vt-node-tag' }, [ _('default route') ]) : null;
h.gw = E('button', { 'type': 'button', 'class': 'vt-node vt-node-gw', 'click': function() { self.openDrawer('uplink', ''); } }, [
E('span', { 'class': 'vt-node-ico' }, [ icon('globe') ]),
E('span', { 'class': 'vt-node-body' }, [ h.gwKicker, h.gwTitle, h.gwSub ])
E('span', { 'class': 'vt-node-body' }, [
h.gwTag ? E('span', { 'class': 'vt-node-kicker vt-node-kicker-tag' }, [ h.gwKicker, h.gwTag ]) : h.gwKicker,
h.gwTitle, h.gwSub
])
]);
h.lines = extra.map(function(x) {
/* its rates as two items, so a narrow node wraps them rather than cut them */
var ln = { id: x.iface, kicker: span('vt-node-name'), title: span('vt-node-title'), dn: E('span'), up: E('span') };
ln.el = E('button', { 'type': 'button', 'class': 'vt-node vt-node-gw vt-node-line', 'click': function() { self.openDrawer('uplink', ln.id); } }, [
E('span', { 'class': 'vt-node-ico' }, [ icon('globe') ]),
E('span', { 'class': 'vt-node-body' }, [ E('span', { 'class': 'vt-node-kicker' }, [ ln.kicker ]), ln.title, E('span', { 'class': 'vt-node-sub vt-node-rates vt-tn' }, [ ln.dn, ln.up ]) ])
]);
return ln;
});
h.linesEl = extra.length ? E('div', { 'class': 'vt-lines' }, h.lines.map(function(ln) { return ln.el; })) : null;
setClass(path, 'vt-path' + (extra.length ? ' vt-path-lines' : ''));
h.dnVal = E('b'); h.dnUnit = E('small'); h.upVal = E('b'); h.upUnit = E('small'); h.linkMeta = span('vt-link-meta');
/* a full-tunnel VPN over the line: named on the link it runs through */
h.tunName = span('vt-link-tunnel-name'); h.tunProto = span('vt-link-tunnel-proto');
h.tunnel = E('span', { 'class': 'vt-link-tunnel', 'hidden': '' }, [ icon('lock'), h.tunName, h.tunProto ]);
h.link = E('div', { 'class': 'vt-link-label' }, [
E('span', { 'class': 'vt-link-rate vt-link-down' }, [ icon('down'), h.dnVal, h.dnUnit ]),
E('span', { 'class': 'vt-link-below' }, [
E('span', { 'class': 'vt-link-rate vt-link-up' }, [ icon('up'), h.upVal, h.upUnit ]),
h.linkMeta
h.linkMeta,
h.tunnel
])
]);
@@ -1782,6 +1840,7 @@ return view.extend({
h.branches = E('div', { 'class': 'vt-branches' });
h.watch = [ path, h.gw, h.link, h.ap, h.branches ];
h.lines.forEach(function(ln) { h.watch.push(ln.el); });
if (m.wired) {
m.networks.filter(function(n) { return n.up; }).slice(0, 8).forEach(function(n) {
var b = { id: n.name, ssids: [], kicker: span('vt-node-kicker'), title: span('vt-node-title'), addr: span('vt-node-sub vt-mono'), sub: span('vt-node-sub vt-tn') };
@@ -1844,6 +1903,8 @@ return view.extend({
path.insertBefore(h.gw, this.el.wires);
path.insertBefore(h.link, this.el.wires);
/* after the main line's rates: on a phone they stay with it */
if (h.linesEl) path.insertBefore(h.linesEl, this.el.wires);
path.insertBefore(h.ap, this.el.wires);
path.insertBefore(h.branches, this.el.wires);
this.hero = h;
@@ -1861,7 +1922,7 @@ return view.extend({
name once one is known beyond its vendor */
var gwHost = (m.wiredHosts || []).filter(function(c) { return c.gateway; })[0];
var gwName = gwHost && /^(alias|dhcp|mdns|dns)$/.test(gwHost.nameSource) ? gwHost.name : null;
txt(h.gwKicker, up && up.isWan ? _('Internet') : _('Gateway'));
txt(h.gwKicker, h.lines.length && up ? up.iface : up && up.isWan ? _('Internet') : _('Gateway'));
txt(h.gwTitle, gwName || (up && up.gateway ? up.gateway : up && up.defaultRoute ? up.l3dev : _('No default route', 'vantage')));
txt(h.gwSub, gwName ? up.gateway : up && up.dns.length ? _('DNS %s').format(up.dns[0]) : _('Reachability not probed'));
@@ -1870,6 +1931,13 @@ return view.extend({
txt(h.upVal, upR ? fmt.bitsParts(upR.tx).v : fmt.DASH);
txt(h.upUnit, upR ? fmt.bitsParts(upR.tx).u : '');
txt(h.linkMeta, up ? [ up.dev, up.speed ? (up.speed.mbps >= 1000 ? (up.speed.mbps / 1000) + 'G' : up.speed.mbps + 'M') + (up.speed.duplex === 'full' ? ' FD' : up.speed.duplex === 'half' ? ' HD' : '') : null ].filter(Boolean).join(' · ') : _('no uplink'));
var tun = up && up.tunnel, tunR = tun ? st.devRates[tun.dev] : null;
if (h.tunnel.hidden !== !tun) h.tunnel.hidden = !tun;
txt(h.tunName, tun ? tun.iface : '');
txt(h.tunProto, tun && tun.proto ? tun.proto : '');
setTitle(h.tunnel, tun ? _('Traffic to the internet goes through %s, a tunnel over this line').format(tun.iface) +
(tunR ? ' · ↓ ' + fmt.bits(tunR.rx) + ' · ↑ ' + fmt.bits(tunR.tx) : '') : '');
if (h.gwTag) txt(h.gwTag, tun ? _('default route via %s').format(tun.iface) : _('default route'));
txt(h.apKicker, m.wired ? _('This device') : _('This access point'));
txt(h.apTitle, d.model);
@@ -1879,6 +1947,22 @@ return view.extend({
h.upRate = upR ? upR.rx + upR.tx : 0;
if (this.backdrop) this.backdrop.update({ down: upR ? upR.rx : null, up: upR ? upR.tx : null, origin: h.ap });
var lines = Object.create(null);
this.extraLines().forEach(function(x) { lines[x.iface] = x; });
h.lines.forEach(function(ln) {
var x = lines[ln.id], r = x ? st.devRates[x.statsDev] : null;
if (!x) return;
setTitle(ln.el, [ x.iface, x.dev, x.speed ? model.speedLabel(x.speed) : null ].filter(Boolean).join(' · '));
setClass(ln.el, 'vt-node vt-node-gw vt-node-line' + (x.carrier === false ? ' vt-node-off' : ''));
txt(ln.kicker, x.iface);
txt(ln.title, x.gateway || x.l3dev);
var p = r && !(r.rx === 0 && r.tx === 0) ? fmt.rateParts(r.rx, r.tx) : null;
txt(ln.dn, p ? '↓ ' + p.dn : r ? _('idle') : fmt.DASH);
txt(ln.up, p ? '↑ ' + p.up + (p.unit ? ' ' + p.unit : '') : '');
ln.active = !!(r && r.rx + r.tx > 50e3);
ln.rate = r ? r.rx + r.tx : 0;
});
if (m.wired) {
var nets = Object.create(null);
m.networks.forEach(function(n) { if (n.up && !nets[n.name]) nets[n.name] = n; });
@@ -1976,7 +2060,8 @@ return view.extend({
wireList: function() {
var h = this.hero, out = [];
if (!h) return out;
out.push({ key: 'gw', from: h.gw, to: h.ap, active: h.upActive, speed: wires.speed(h.upRate), main: true });
out.push({ key: 'gw', from: h.gw, to: h.ap, active: h.upActive, speed: wires.speed(h.upRate), main: true, tunnel: !!(this.m && this.m.uplink && this.m.uplink.tunnel) });
h.lines.forEach(function(ln, k) { out.push({ key: 'l:' + ln.id, from: ln.el, to: h.ap, active: ln.active, speed: wires.speed(ln.rate), line: k }); });
h.radios.forEach(function(b) {
out.push({ key: 'r:' + b.id, from: h.ap, to: b.el, active: b.active, speed: wires.speed(b.rate) });
b.ssids.forEach(function(sn) { out.push({ key: 's:' + b.id + ':' + sn.id, from: b.el, to: sn.el, active: sn.active, speed: 1 }); });
@@ -2023,7 +2108,7 @@ return view.extend({
svg.appendChild(g.flow);
self.el.flows.appendChild(g.fw);
}
var c = wires.curve(rect(w.from), rect(w.to));
var c = w.line != null ? wires.line(rect(w.from), rect(w.to), w.line) : wires.curve(rect(w.from), rect(w.to));
if (g.d !== c.d) {
g.base.setAttribute('d', c.d);
g.flow.setAttribute('d', c.d);
@@ -2052,7 +2137,7 @@ return view.extend({
this.wireList().forEach(function(w) {
var g = map[w.key];
if (!g) return;
setClass(g.base, 'vt-wire' + (w.main ? ' vt-wire-main' : '') + (w.active ? ' vt-wire-on' : ''));
setClass(g.base, 'vt-wire' + (w.main ? ' vt-wire-main' : '') + (w.tunnel ? ' vt-wire-tunnel' : '') + (w.active ? ' vt-wire-on' : ''));
setClass(g.flow, 'vt-flow vt-flow-' + w.speed + (w.active ? ' vt-flow-on' : ''));
self.syncFlow(g, !!(w.active && !still && g.d), w.speed);
});
@@ -2253,7 +2338,8 @@ return view.extend({
]));
},
/* one small multiple per link: the uplink, then each radio, each with
/* one small multiple per link: the uplink, the other lines to the
internet, then each radio, each with
its own y scale (a busy uplink would flatten an idle radio), on a
shared time axis and crosshair. Download (towards the clients) and
upload each keep their direction's colour; how they share the plot
@@ -2262,7 +2348,8 @@ return view.extend({
download, a radio sends it. */
chartSeries: function() {
var m = this.m;
var list = [ { key: 'uplink', label: m.uplink ? _('Uplink %s').format(m.uplink.dev) : _('Uplink', 'vantage'), dn: 1, up: 2 } ];
var list = [ { key: m.uplink ? lineKey(m.uplink) : 'uplink', label: m.uplink ? _('Uplink %s').format(m.uplink.dev) : _('Uplink', 'vantage'), dn: 1, up: 2 } ];
this.extraLines().forEach(function(x) { list.push({ key: lineKey(x), label: _('Uplink %s').format(x.dev), dn: 1, up: 2 }); });
m.radios.forEach(function(r) { list.push({ key: r.id, label: _('%s radio').format(r.bandLabel), dn: 2, up: 1 }); });
return list;
},
@@ -2278,7 +2365,10 @@ return view.extend({
start part way across and the minute labels stay where they are
(a span that grew with the history moved them every poll, #71) */
var dm = series.domain(list.map(function(s) { return net[s.key]; }), now, WINDOW_MS, WINDOW_MS);
dm.full = dm.age >= WINDOW_MS - 1000;
/* history keeps only samples newer than the window, so its oldest is
up to one poll short of five minutes: allow two polls, or the
subtitle flips to "collecting" with a full history */
dm.full = dm.age >= WINDOW_MS - POLL_S * 2000;
var ticks = [];
for (var k = 1; k * 60000 < dm.span - 20000; k++) ticks.push(k);
var rows = [], samples = 0;
@@ -2857,9 +2947,8 @@ return view.extend({
var bar = E('span', { 'class': 'vt-mini-fill' });
setW(bar, maxRate > 0 ? total / maxRate * 100 : 0);
var tags = [];
var up = this.m && this.m.uplink;
if (c.gateway) tags.push(pill(_('Gateway'), 'accent', _('The router this device sends its traffic to')));
else if (up && up.port && c.port === up.port) tags.push(pill(_('Upstream'), 'muted', _('Reached through the uplink port %s, not connected to this device').format(c.port)));
else if (this.linePort(c.port)) tags.push(pill(_('Upstream'), 'muted', _('Reached through the uplink port %s, not connected to this device').format(c.port)));
if (c.vlan != null) tags.push(pill(_('VLAN %d').format(c.vlan), 'muted'));
var speed = c.speed ? fmt.phyRate(c.speed.mbps * 1000) : fmt.DASH;
var link = E('td', { 'class': 'vt-td-link', 'data-title': _('Link', 'vantage') }, [
@@ -3033,7 +3122,7 @@ return view.extend({
this.ui.editing = null;
/* the address names what is shown, also when the open drawer
moves on (from a radio to one of its clients) */
try { history.replaceState(null, '', '#' + kind + '=' + encodeURIComponent(id)); } catch (e) {}
try { history.replaceState(null, '', '#' + kind + (kind === 'uplink' && !id ? '' : '=' + encodeURIComponent(id))); } catch (e) {}
if (!el.drawer.hidden) { this.paintDrawer(); this.paintClients(); return; }
this.lastFocus = document.activeElement;
el.drawer.hidden = false;
@@ -3056,16 +3145,19 @@ return view.extend({
},
/* A deep link names an existing thing or nothing: a client by a
station MAC, a radio this device reports; uplink and device take no
id. Anything else is dropped from the address bar, so a crafted link
cannot put its own text in the drawer title. */
station MAC, a radio this device reports, a line to the internet by
its interface (#uplink alone: the main one, whichever it is); the
device takes no id. Anything else is dropped from the address bar,
so a crafted link cannot put its own text in the drawer title. */
openFromHash: function() {
var hash = location.hash || '', m = /^#(client|radio|uplink|device)=(.{1,128})$/.exec(hash), id = null;
if (hash === '#uplink') { this.openDrawer('uplink', ''); return; }
if (m) {
try { id = decodeURIComponent(m[2]); } catch (e) { id = null; }
if (id == null) { /* malformed escape */ }
else if (m[1] === 'client') id = names.normMac(id);
else if (m[1] === 'radio') id = this.m && this.m.radios.some(function(r) { return r.id === id; }) ? id : null;
else if (m[1] === 'uplink') id = ((this.m && this.m.uplinks) || []).some(function(x) { return x.iface === id; }) ? id : '';
else id = m[1];
}
if (id != null) { this.openDrawer(m[1], id); return; }
@@ -3081,7 +3173,7 @@ return view.extend({
var content;
if (d.kind === 'client') content = this.drawerClient(d.id);
else if (d.kind === 'radio') content = this.drawerRadio(d.id);
else if (d.kind === 'uplink') content = this.drawerUplink();
else if (d.kind === 'uplink') content = this.drawerUplink(d.id);
else content = this.drawerDevice();
dom.content(el.drawerTitle, content.title);
dom.content(el.drawerBody, content.body);
@@ -3368,9 +3460,26 @@ return view.extend({
};
},
drawerUplink: function() {
var up = this.m.uplink, r = this.st.uplinkRate;
/* the main line (id ''), whichever interface it is, or one line by
its interface: still that one after a failover makes it the main
line, and said to be down when it is no line any more (never
another interface under its name) */
drawerUplink: function(id) {
var m = this.m, st = this.st, lines = m.uplinks || [];
var up = id ? lines.filter(function(x) { return x.iface === id; })[0] : m.uplink;
var r = up && up === m.uplink ? st.uplinkRate : up ? st.devRates[up.statsDev] || null : null;
var hist = up ? st.hist.net[lineKey(up)] : null, several = lines.length > 1;
var tunR = up && up.tunnel ? st.devRates[up.tunnel.dev] || null : null;
function errors(n) { return fmt.N_(n, '%s error', '%s errors').format(fmt.count(n)); }
if (id && !up) return {
title: [ E('span', { 'class': 'vt-drawer-ico' }, [ icon('globe') ]), E('div', { 'class': 'vt-drawer-titles' }, [
E('div', { 'class': 'vt-drawer-name' }, [ _('Uplink · %s').format(id) ])
]) ],
body: [
E('div', { 'class': 'vt-dactions' }, [ pageLink(insight.PAGES.network, _('Interfaces')) ]),
E('p', { 'class': 'vt-empty-inline' }, [ _('This line is down or has no default route now.') ])
]
};
if (!up) return { title: [ E('span', { 'class': 'vt-drawer-name' }, [ _('Uplink', 'vantage') ]) ], body: [ E('p', { 'class': 'vt-empty-inline' }, [ _('No interface with a route is up.') ]) ] };
var s = up.stats || {};
return {
@@ -3384,17 +3493,22 @@ return view.extend({
E('div', { 'class': 'vt-dcard' }, [ E('div', { 'class': 'vt-dcard-label' }, [ _('From network') ]), E('div', { 'class': 'vt-dcard-value' }, [ r ? fmt.bits(r.rx) : fmt.DASH ]), E('div', { 'class': 'vt-dcard-sub' }, [ _('received on %s').format(up.dev) ]) ]),
E('div', { 'class': 'vt-dcard' }, [ E('div', { 'class': 'vt-dcard-label' }, [ _('To network') ]), E('div', { 'class': 'vt-dcard-value' }, [ r ? fmt.bits(r.tx) : fmt.DASH ]), E('div', { 'class': 'vt-dcard-sub' }, [ _('sent on %s').format(up.dev) ]) ])
]),
E('div', { 'class': 'vt-dspark' }, [ sparkline(this.st.hist.net.uplink || [], [ 1, 2 ], 0, 'vt-spark-up', { min: 1000, scale: bitsLabel }) ]),
E('div', { 'class': 'vt-dspark' }, [ sparkline(hist || [], [ 1, 2 ], 0, 'vt-spark-up', { min: 1000, scale: bitsLabel }) ]),
this.section(_('Link', 'vantage'), [ E('dl', { 'class': 'vt-kvs' }, [
kv(_('Carrier'), up.carrier === false ? _('No link') : _('Up', 'vantage')),
kv(_('Speed', 'vantage'), model.speedLabel(up.speed)),
kv(_('Protocol'), up.proto || fmt.DASH),
kv(_('Interface up for'), up.uptime != null ? fmt.duration(up.uptime) : fmt.DASH)
kv(_('Interface up for'), up.uptime != null ? fmt.duration(up.uptime) : fmt.DASH),
/* a full-tunnel VPN over this line: ordinary traffic goes through it */
up.tunnel ? kv(_('Tunnel'), up.tunnel.iface + (up.tunnel.proto ? ' · ' + up.tunnel.proto : '')) : '',
up.tunnel ? kv(_('In the tunnel'), tunR ? '↓ ' + fmt.bits(tunR.rx) + ' · ↑ ' + fmt.bits(tunR.tx) : fmt.DASH) : ''
]) ]),
this.section(_('Addresses'), [ E('dl', { 'class': 'vt-kvs' }, [
kv(_('IPv4'), up.ipv4.join('\n') || fmt.DASH, true),
kv(_('IPv6'), up.ipv6.join('\n') || fmt.DASH, true),
kv(_('Gateway'), up.gateway || fmt.DASH, true),
/* beside other lines, the metric says which one the kernel takes */
several ? kv(_('Route metric'), up.metric != null ? String(up.metric) : fmt.DASH, true) : '',
kv(_('DNS'), up.dns.join('\n') || fmt.DASH, true)
]) ]),
this.section(_('Counters since boot'), [ E('dl', { 'class': 'vt-kvs' }, [
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"luci-app-vantage": {
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless), its temperature sensors (luci.vantage thermal), the wired hosts its bridges have learned (luci.vantage wired: MAC, port and when last heard, and which switch ports a port's VLAN has) and the link state of those switch ports (luci getSwconfigPortState)",
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless), its temperature sensors (luci.vantage thermal), the wired hosts its bridges have learned (luci.vantage wired: MAC, port and when last heard, and which switch ports a port's VLAN has), the link state of those switch ports (luci getSwconfigPortState) and the default route the kernel takes to the internet (luci.vantage routes: family, device and metric, no gateways)",
"read": {
"file": {
"/proc/stat": [ "read" ]
@@ -12,7 +12,7 @@
"network.device": [ "status" ],
"luci-rpc": [ "getHostHints" ],
"luci": [ "getSwconfigPortState" ],
"luci.vantage": [ "thermal", "wired", "wireless" ],
"luci.vantage": [ "routes", "thermal", "wired", "wireless" ],
"iwinfo": [ "assoclist", "info" ],
"hostapd.*": [ "get_clients", "get_status" ],
"umdns": [ "hosts" ],
@@ -26,6 +26,18 @@
// /etc/config/network (device, vlan, vid and ports, nothing
// else), or board.json's roles for a switch those give no
// VLAN. Read group luci-app-vantage.
// routes the route the kernel takes to the internet, IPv4 and IPv6:
// a read-only lookup of each family (as `ip route get
// fibmatch`) of a documentation address no network uses, so
// the answer is the default route ordinary traffic takes.
// For each family whose answer is a unicast default route in
// the main table: its family (4 or 6), the device it leaves by
// (null over several next hops) and its metric; no gateways.
// netifd lists the routes it asked for; of two with the same
// metric the kernel keeps the one added last. routes is null
// when a lookup could not be sent. One lookup a family, so
// the work does not grow with the routing table. 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
@@ -52,6 +64,8 @@ const THERMAL = '/sys/class/thermal';
const MAX_ZONES = 16;
const MAX_HOSTS = 512; // wired hosts per reply
const MAX_PER_PORT = 128; // of them on one port (the uplink's would take them all)
const PROBE4 = '198.51.100.1'; // routes: looked up, never sent to (documentation ranges)
const PROBE6 = '2001:db8::1';
const NET = '/sys/class/net';
const FDB_LIMIT = 4096; // forwarding entries, all bridges together, before wired() lists none
const FDB_ENTRY = 16; // sizeof(struct __fdb_entry), brforward's record
@@ -478,6 +492,31 @@ function wired() {
return out;
}
// [ { family, dev, metric } ] for the families whose lookup answers with
// a unicast default route in the main table (no dst), IPv4 then IPv6; dev
// null over several next hops (multipath, no single oif). null when a
// lookup could not be sent; false is the kernel's error, for a lookup that
// there is no route (ENETUNREACH), so that family is left out.
function routes() {
let out = [];
for (let q in [ { family: rtnl.const.AF_INET, dst: PROBE4 }, { family: rtnl.const.AF_INET6, dst: PROBE6 } ]) {
let r = rtnl.request(rtnl.const.RTM_GETROUTE, 0, { family: q.family, dst: q.dst, flags: rtnl.const.RTM_F_FIB_MATCH });
if (r === null)
return null;
if (type(r) != 'object' || r.family != q.family || r.dst != null || r.table != rtnl.const.RT_TABLE_MAIN || r.type != rtnl.const.RTN_UNICAST)
continue;
let m = r.priority ?? 0;
push(out, { family: (q.family == rtnl.const.AF_INET) ? 4 : 6, dev: netdev(r.oif), metric: (type(m) == 'int' && m >= 0) ? m : 0 });
}
return out;
}
// a switch port's number (0..63), or null
function switch_port(v) {
return (type(v) == 'int' && v >= 0 && v < 64) ? v : null;
@@ -631,6 +670,12 @@ const methods = {
}
},
routes: {
call: function() {
return { routes: routes() };
}
},
set_alias: {
args: { mac: 'mac', name: 'name', icon: 'icon' },
call: function(request) {
+1 -1
View File
@@ -11,7 +11,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-vantage
PKG_VERSION:=1.0.15
PKG_RELEASE:=32
PKG_RELEASE:=33
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage theme
@@ -2594,6 +2594,9 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
#packages {
width: 100% !important;
}
vt-brand-word {
margin:0;
}
.vt-login-card {
padding: var(--sp-6);
background: var(--s1);
@@ -60,7 +60,7 @@
<circle cx="21.5" cy="10.5" r="1.9" class="vt-brand-dot"></circle>
<circle cx="21.5" cy="21.5" r="1.9" class="vt-brand-dot"></circle>
</svg>
<span class="vt-brand-word">{{ hostname }}</span>
<h3 class="vt-brand-word">{{ hostname }}</h3>
</a>
<ul class="vt-rail-list" id="vt-rail-list"></ul>
<div class="vt-rail-foot">