luci-mod-status: introduce ethernet port status view
authorJo-Philipp Wich <jo@mein.io>
Tue, 2 Aug 2022 13:41:31 +0000 (15:41 +0200)
committerJo-Philipp Wich <jo@mein.io>
Thu, 27 Jul 2023 11:09:49 +0000 (13:09 +0200)
This commit adds an ethernet port status overview to the main status page.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
(cherry picked from commit 4e4662481759b705fc65f71de384e08e9a3f49bc)

modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/29_ports.js [new file with mode: 0644]
modules/luci-mod-status/root/usr/share/rpcd/acl.d/luci-mod-status-index.json

diff --git a/modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/29_ports.js b/modules/luci-mod-status/htdocs/luci-static/resources/view/status/include/29_ports.js
new file mode 100644 (file)
index 0000000..31af6f4
--- /dev/null
@@ -0,0 +1,360 @@
+'use strict';
+'require baseclass';
+'require fs';
+'require ui';
+'require uci';
+'require network';
+'require firewall';
+
+function isString(v)
+{
+       return typeof(v) === 'string' && v !== '';
+}
+
+function resolveVLANChain(ifname, bridges, mapping)
+{
+       while (!mapping[ifname]) {
+               var m = ifname.match(/^(.+)\.([^.]+)$/);
+
+               if (!m)
+                       break;
+
+               if (bridges[m[1]]) {
+                       if (bridges[m[1]].vlan_filtering)
+                               mapping[ifname] = bridges[m[1]].vlans[m[2]];
+                       else
+                               mapping[ifname] = bridges[m[1]].ports;
+               }
+               else if (/^[0-9]{1,4}$/.test(m[2]) && m[2] <= 4095) {
+                       mapping[ifname] = [ m[1] ];
+               }
+               else {
+                       break;
+               }
+
+               ifname = m[1];
+       }
+}
+
+function buildVLANMappings(mapping)
+{
+       var bridge_vlans = uci.sections('network', 'bridge-vlan'),
+           vlan_devices = uci.sections('network', 'device'),
+           interfaces = uci.sections('network', 'interface'),
+           bridges = {};
+
+       /* find bridge VLANs */
+       for (var i = 0, s; (s = bridge_vlans[i]) != null; i++) {
+               if (!isString(s.device) || !/^[0-9]{1,4}$/.test(s.vlan) || +s.vlan > 4095)
+                       continue;
+
+               var aliases = L.toArray(s.alias),
+                   ports = L.toArray(s.ports),
+                   br = bridges[s.device] = (bridges[s.device] || { ports: [], vlans: {}, vlan_filtering: true });
+
+               br.vlans[s.vlan] = [];
+
+               for (var j = 0; j < ports.length; j++) {
+                       var port = ports[j].replace(/:[ut*]+$/, '');
+
+                       if (br.ports.indexOf(port) === -1)
+                               br.ports.push(port);
+
+                       br.vlans[s.vlan].push(port);
+               }
+
+               for (var j = 0; j < aliases.length; j++)
+                       if (aliases[j] != s.vlan)
+                               br.vlans[aliases[j]] = br.vlans[s.vlan];
+       }
+
+       /* find bridges, VLAN devices */
+       for (var i = 0, s; (s = vlan_devices[i]) != null; i++) {
+               if (s.type == 'bridge') {
+                       if (!isString(s.name))
+                               continue;
+
+                       var ports = L.toArray(s.ports),
+                           br = bridges[s.name] || (bridges[s.name] = { ports: [], vlans: {}, vlan_filtering: false });
+
+                       if (s.vlan_filtering == '0')
+                               br.vlan_filtering = false;
+                       else if (s.vlan_filtering == '1')
+                               br.vlan_filtering = true;
+
+                       for (var j = 0; j < ports.length; j++)
+                               if (br.ports.indexOf(ports[j]) === -1)
+                                       br.ports.push(ports[j]);
+
+                       mapping[s.name] = br.ports;
+               }
+               else if (s.type == '8021q' || s.type == '8021ad') {
+                       if (!isString(s.name) || !isString(s.vid) || !isString(s.ifname))
+                               continue;
+
+                       /* parent device is a bridge */
+                       if (bridges[s.ifname]) {
+                               /* parent bridge is VLAN enabled, device refers to VLAN ports */
+                               if (bridges[s.ifname].vlan_filtering)
+                                       mapping[s.name] = bridges[s.ifname].vlans[s.vid];
+
+                               /* parent bridge is not VLAN enabled, device refers to all bridge ports */
+                               else
+                                       mapping[s.name] = bridges[s.ifname].ports;
+                       }
+
+                       /* parent is a simple netdev */
+                       else {
+                               mapping[s.name] = [ s.ifname ];
+                       }
+
+                       resolveVLANChain(s.ifname, bridges, mapping);
+               }
+       }
+
+       /* resolve VLAN tagged interfaces in bridge ports */
+       for (var brname in bridges) {
+               for (var i = 0; i < bridges[brname].ports.length; i++)
+                       resolveVLANChain(bridges[brname].ports[i], bridges, mapping);
+
+               for (var vid in bridges[brname].vlans)
+                       for (var i = 0; i < bridges[brname].vlans[vid].length; i++)
+                               resolveVLANChain(bridges[brname].vlans[vid][i], bridges, mapping);
+       }
+
+       /* find implicit VLAN devices */
+       for (var i = 0, s; (s = interfaces[i]) != null; i++) {
+               if (!isString(s.device))
+                       continue;
+
+               resolveVLANChain(s.device, bridges, mapping);
+       }
+}
+
+function resolveVLANPorts(ifname, mapping)
+{
+       var ports = [];
+
+       if (mapping[ifname])
+               for (var i = 0; i < mapping[ifname].length; i++)
+                       ports.push.apply(ports, resolveVLANPorts(mapping[ifname][i], mapping));
+       else
+               ports.push(ifname);
+
+       return ports.sort(L.naturalCompare);
+}
+
+function buildInterfaceMapping(zones, networks) {
+       var vlanmap = {},
+           portmap = {},
+           netmap = {};
+
+       buildVLANMappings(vlanmap);
+
+       for (var i = 0; i < networks.length; i++) {
+               var l3dev = networks[i].getDevice();
+
+               if (!l3dev)
+                       continue;
+
+               var ports = resolveVLANPorts(l3dev.getName(), vlanmap);
+
+               for (var j = 0; j < ports.length; j++) {
+                       portmap[ports[j]] = portmap[ports[j]] || { networks: [], zones: [] };
+                       portmap[ports[j]].networks.push(networks[i]);
+               }
+
+               netmap[networks[i].getName()] = networks[i];
+       }
+
+       for (var i = 0; i < zones.length; i++) {
+               var networknames = zones[i].getNetworks();
+
+               for (var j = 0; j < networknames.length; j++) {
+                       if (!netmap[networknames[j]])
+                               continue;
+
+                       var l3dev = netmap[networknames[j]].getDevice();
+
+                       if (!l3dev)
+                               continue;
+
+                       var ports = resolveVLANPorts(l3dev.getName(), vlanmap);
+
+                       for (var k = 0; k < ports.length; k++) {
+                               portmap[ports[k]] = portmap[ports[k]] || { networks: [], zones: [] };
+
+                               if (portmap[ports[k]].zones.indexOf(zones[i]) === -1)
+                                       portmap[ports[k]].zones.push(zones[i]);
+                       }
+               }
+       }
+
+       return portmap;
+}
+
+function formatSpeed(speed, duplex) {
+       if (speed && duplex) {
+               var d = (duplex == 'half') ? '\u202f(H)' : '',
+                   e = E('span', { 'title': _('Speed: %d Mibit/s, Duplex: %s').format(speed, duplex) });
+
+               switch (speed) {
+               case 10:    e.innerText = '10\u202fM' + d;  break;
+               case 100:   e.innerText = '100\u202fM' + d; break;
+               case 1000:  e.innerText = '1\u202fGbE' + d; break;
+               case 2500:  e.innerText = '2.5\u202fGbE';   break;
+               case 5000:  e.innerText = '5\u202fGbE';     break;
+               case 10000: e.innerText = '10\u202fGbE';    break;
+               case 25000: e.innerText = '25\u202fGbE';    break;
+               case 40000: e.innerText = '40\u202fGbE';    break;
+               default:    e.innerText = '%d\u202fMbE%s'.format(speed, d);
+               }
+
+               return e;
+       }
+
+       return _('no link');
+}
+
+function formatStats(portdev) {
+       var stats = portdev._devstate('stats');
+
+       return ui.itemlist(E('span'), [
+               _('Received bytes'), '%1024mB'.format(stats.rx_bytes),
+               _('Received packets'), '%1000mPkts.'.format(stats.rx_packets),
+               _('Received multicast'), '%1000mPkts.'.format(stats.multicast),
+               _('Receive errors'), '%1000mPkts.'.format(stats.rx_errors),
+               _('Receive dropped'), '%1000mPkts.'.format(stats.rx_dropped),
+
+               _('Transmitted bytes'), '%1024mB'.format(stats.tx_bytes),
+               _('Transmitted packets'), '%1000mPkts.'.format(stats.tx_packets),
+               _('Transmit errors'), '%1000mPkts.'.format(stats.tx_errors),
+               _('Transmit dropped'), '%1000mPkts.'.format(stats.tx_dropped),
+
+               _('Collisions seen'), stats.collisions
+       ]);
+}
+
+function renderNetworkBadge(network, zonename) {
+       var l3dev = network.getDevice();
+       var span = E('span', { 'class': 'ifacebadge', 'style': 'margin:.125em 0' }, [
+               E('span', {
+                       'class': 'zonebadge',
+                       'title': zonename ? _('Part of zone %q').format(zonename) : _('No zone assigned'),
+                       'style': firewall.getZoneColorStyle(zonename)
+               }, '\u202f'),
+               '\u202f', network.getName(), ': '
+       ]);
+
+       if (l3dev)
+               span.appendChild(E('img', {
+                       'title': l3dev.getI18n(),
+                       'src': L.resource('icons/%s%s.png'.format(l3dev.getType(), l3dev.isUp() ? '' : '_disabled'))
+               }));
+       else
+               span.appendChild(E('em', _('(no interfaces attached)')));
+
+       return span;
+}
+
+function renderNetworksTooltip(pmap) {
+       var res = [ null ],
+           zmap = {};
+
+       for (var i = 0; pmap && i < pmap.zones.length; i++) {
+               var networknames = pmap.zones[i].getNetworks();
+
+               for (var k = 0; k < networknames.length; k++)
+                       zmap[networknames[k]] = pmap.zones[i].getName();
+       }
+
+       for (var i = 0; pmap && i < pmap.networks.length; i++)
+               res.push(E('br'), renderNetworkBadge(pmap.networks[i], zmap[pmap.networks[i].getName()]));
+
+       if (res.length > 1)
+               res[0] = N_((res.length - 1) / 2, 'Part of network:', 'Part of networks:');
+       else
+               res[0] = _('Port is not part of any network');
+
+       return E([], res);
+}
+
+return baseclass.extend({
+       title: _('Port status'),
+
+       load: function() {
+               return Promise.all([
+                       L.resolveDefault(fs.read('/etc/board.json'), '{}'),
+                       firewall.getZones(),
+                       network.getNetworks(),
+                       uci.load('network')
+               ]);
+       },
+
+       render: function(data) {
+               if (L.hasSystemFeature('swconfig'))
+                       return null;
+
+               var board = JSON.parse(data[0]),
+                   known_ports = [],
+                   port_map = buildInterfaceMapping(data[1], data[2]);
+
+               if (L.isObject(board) && L.isObject(board.network)) {
+                       for (var k = 'lan'; k != null; k = (k == 'lan') ? 'wan' : null) {
+                               if (!L.isObject(board.network[k]))
+                                       continue;
+
+                               if (Array.isArray(board.network[k].ports))
+                                       for (let i = 0; i < board.network[k].ports.length; i++)
+                                               known_ports.push({
+                                                       role: k,
+                                                       device: board.network[k].ports[i],
+                                                       netdev: network.instantiateDevice(board.network[k].ports[i])
+                                               });
+                               else if (typeof(board.network[k].device) == 'string')
+                                       known_ports.push({
+                                               role: k,
+                                               device: board.network[k].device,
+                                               netdev: network.instantiateDevice(board.network[k].device)
+                                       });
+                       }
+               }
+
+               known_ports.sort(function(a, b) {
+                       return L.naturalCompare(a.device, b.device);
+               });
+
+               return E('div', { 'style': 'display:grid;grid-template-columns:repeat(auto-fit, minmax(70px, 1fr));margin-bottom:1em' }, known_ports.map(function(port) {
+                       var speed = port.netdev.getSpeed(),
+                           duplex = port.netdev.getDuplex(),
+                           pmap = port_map[port.netdev.getName()],
+                           pzones = (pmap && pmap.zones.length) ? pmap.zones.sort(function(a, b) { return L.naturalCompare(a.getName(), b.getName()) }) : [ null ];
+
+                       return E('div', { 'class': 'ifacebox', 'style': 'margin:.25em;min-width:70px;max-width:100px' }, [
+                               E('div', { 'class': 'ifacebox-head', 'style': 'font-weight:bold' }, [ port.netdev.getName() ]),
+                               E('div', { 'class': 'ifacebox-body' }, [
+                                       E('img', { 'src': L.resource('icons/port_%s.png').format((speed && duplex) ? 'up' : 'down') }),
+                                       E('br'),
+                                       formatSpeed(speed, duplex)
+                               ]),
+                               E('div', { 'class': 'ifacebox-head cbi-tooltip-container', 'style': 'display:flex' }, [
+                                       E([], pzones.map(function(zone) {
+                                               return E('div', {
+                                                       'class': 'zonebadge',
+                                                       'style': 'cursor:help;flex:1;height:3px;' + firewall.getZoneColorStyle(zone)
+                                               });
+                                       })),
+                                       E('span', { 'class': 'cbi-tooltip left' }, [ renderNetworksTooltip(pmap) ])
+                               ]),
+                               E('div', { 'class': 'ifacebox-body' }, [
+                                       E('div', { 'class': 'cbi-tooltip-container', 'style': 'text-align:left;font-size:80%' }, [
+                                               '\u25b2\u202f%1024.1mB'.format(port.netdev.getTXBytes()),
+                                               E('br'),
+                                               '\u25bc\u202f%1024.1mB'.format(port.netdev.getRXBytes()),
+                                               E('span', { 'class': 'cbi-tooltip' }, formatStats(port.netdev))
+                                       ]),
+                               ])
+                       ]);
+               }));
+       }
+});
index 7c2cd0998f3395576cc45f4d0f783bbc052bebf4..83ca3259696a839c5e68ee35fbb4156558940a02 100644 (file)
@@ -3,6 +3,7 @@
                "description": "Grant access to main status display",
                "read": {
                        "file": {
+                               "/etc/board.json": [ "read" ],
                                "/proc/sys/net/netfilter/nf_conntrack_count": [ "read" ],
                                "/proc/sys/net/netfilter/nf_conntrack_max": [ "read" ],
                                "/www/luci-static/resources/view/status/include": [ "list" ]