luci-app-dawn: Implement in JavaScript

This commit re-implements luci-app-dawn in JavaScript, removing the older
lua implementation. Besides a 1-to-1 port, there are some
changes/improvements:

* In both the network overview and the hearing map, replace MAC addresses
  by host name if known.
* In the hearing map, the table is sortable. If the same client is
  connected to multiple access points/frequencies the MAC/host name is
  listed twice, whereas in the lua implementation the second MAC address
  was empty to show it was referring to the same client. This means the
  table can be sorted on any column, and the information remains correct.
* The view in the network overview is a bit different. This table is not
  sortable, because LuCi doesn't seem to like a table inside a table for
  sorting.
* Align the column names between the network overview and the hearing
  table.
* Add tooltips for abbreviations in column names.

Signed-off-by: Daniel Vijge <danielvijge@gmail.com>
This commit is contained in:
Daniel Vijge 2023-10-26 22:58:03 +02:00 committed by Nick Hainke
parent eabf1d020f
commit ea8c0aa2a1
10 changed files with 276 additions and 196 deletions

View file

@ -1,7 +1,7 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for DAWN
LUCI_DEPENDS:=+luci-base +dawn +luci-compat +luci-lib-json
LUCI_DEPENDS:=+luci-base +dawn
include ../../luci.mk

View file

@ -0,0 +1,69 @@
'use strict';
'require baseclass';
'require rpc';
let callDawnGetNetwork, callDawnGetHearingMap, callHostHints;
callDawnGetNetwork = rpc.declare({
object: 'dawn',
method: 'get_network',
expect: { }
});
callDawnGetHearingMap = rpc.declare({
object: 'dawn',
method: 'get_hearing_map',
expect: { }
});
callHostHints = rpc.declare({
object: 'luci-rpc',
method: 'getHostHints',
expect: { }
});
function getAvailableText(available) {
return ( available ? _('Available') : _('Not available') );
}
function getChannelFromFrequency(freq) {
if (freq <= 2400) {
return 0;
}
else if (freq == 2484) {
return 14;
}
else if (freq < 2484) {
return (freq - 2407) / 5;
}
else if (freq >= 4910 && freq <= 4980) {
return (freq - 4000) / 5;
}
else if (freq <= 45000) {
return (freq - 5000) / 5;
}
else if (freq >= 58320 && freq <= 64800) {
return (freq - 56160) / 2160;
}
else {
return 0;
}
}
function getFormattedNumber(num, decimals, divider = 1) {
return (num/divider).toFixed(decimals);
}
function getHostnameFromMAC(hosthints, mac) {
return ( hosthints[mac] && hosthints[mac].name ? hosthints[mac].name : mac);
}
return L.Class.extend({
callDawnGetNetwork: callDawnGetNetwork,
callDawnGetHearingMap: callDawnGetHearingMap,
callHostHints: callHostHints,
getAvailableText: getAvailableText,
getChannelFromFrequency: getChannelFromFrequency,
getFormattedNumber: getFormattedNumber,
getHostnameFromMAC: getHostnameFromMAC
});

View file

@ -0,0 +1,78 @@
'use strict';
'require uci';
'require view';
'require dawn.dawn-common as dawn';
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return Promise.all([
dawn.callDawnGetHearingMap(),
dawn.callHostHints()
]);
},
render: function(data) {
const dawnHearingMapData = data[0];
const hostHintsData = data[1];
const body = E([
E('h2', _('Hearing Map'))
]);
for (let network in dawnHearingMapData) {
body.appendChild(
E('h3', 'SSID: ' + network)
);
let hearing_map_table = E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Client')),
E('th', { 'class': 'th' }, _('Access Point')),
E('th', { 'class': 'th' }, _('Frequency')),
E('th', { 'class': 'th' }, E('span', { 'data-tooltip': _('High Throughput') }, [ _('HT') ])),
E('th', { 'class': 'th' }, E('span', { 'data-tooltip': _('Very High Throughput') }, [ _('VHT') ])),
E('th', { 'class': 'th' }, _('Signal')),
E('th', { 'class': 'th' }, E('span', { 'data-tooltip': _('Received Channel Power Indication') }, [ _('RCPI') ])),
E('th', { 'class': 'th' }, E('span', { 'data-tooltip': _('Received Signal to Noise Indicator') }, [ _('RSNI') ])),
E('th', { 'class': 'th' }, _('Channel Utilization')),
E('th', { 'class': 'th' }, _('Stations Connected')),
E('th', { 'class': 'th' }, _('Score'))
])
]);
let clients = Object.entries(dawnHearingMapData[network]).map(function(client) {
return Object.entries(client[1]).map(function(ap) {
if (ap[1].freq != 0) {
return [
dawn.getHostnameFromMAC(hostHintsData, client[0]),
dawn.getHostnameFromMAC(hostHintsData, ap[0]),
dawn.getFormattedNumber(ap[1].freq, 3, 1000) + ' GHz (' + _('Channel') + ': ' + dawn.getChannelFromFrequency(ap[1].freq) + ')',
dawn.getAvailableText(ap[1].ht_capabilities && ap[1].ht_support),
dawn.getAvailableText(ap[1].vht_capabilities && ap[1].vht_support),
ap[1].signal,
ap[1].rcpi,
ap[1].rsni,
dawn.getFormattedNumber(ap[1].channel_utilization, 2, 2.55) + '%',
ap[1].num_sta,
ap[1].score
]
}
}).flat()
});
cbi_update_table(hearing_map_table, clients, E('em', _('No clients connected.')));
body.appendChild(hearing_map_table);
}
return body;
}
});

View file

@ -0,0 +1,93 @@
'use strict';
'require uci';
'require view';
'require dawn.dawn-common as dawn';
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return Promise.all([
dawn.callDawnGetNetwork(),
dawn.callHostHints()
]);
},
render: function(data) {
const dawnNetworkData = data[0];
const hostHintsData = data[1];
const body = E([
E('h2', _('Network Overview'))
]);
let client_table = {};
for (let network in dawnNetworkData) {
body.appendChild(
E('h3', 'SSID: ' + network)
);
let ap_table = E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th left cbi-section-actions' }, _('Access Point')),
E('th', { 'class': 'th left cbi-section-actions' }, _('Interface')),
E('th', { 'class': 'th left cbi-section-actions' }, _('MAC')),
E('th', { 'class': 'th left cbi-section-actions' }, _('Utilization')),
E('th', { 'class': 'th left cbi-section-actions' }, _('Frequency')),
E('th', { 'class': 'th left cbi-section-actions' }, _('Stations Connected')),
E('th', { 'class': 'th left cbi-section-actions' }, E('span', { 'data-tooltip': _('High Throughput') }, [ _('HT') ])),
E('th', { 'class': 'th left cbi-section-actions' }, E('span', { 'data-tooltip': _('Very High Throughput') }, [ _('VHT') ])),
E('th', { 'class': 'th center cbi-section-actions' }, _('Clients')),
])
]);
let aps = Object.entries(dawnNetworkData[network]).map(function(ap) {
client_table[ap[0]] = E('table', { 'class': 'table cbi-section-table', 'style': 'display: table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Client')),
E('th', { 'class': 'th' }, E('span', { 'data-tooltip': _('High Throughput') }, [ _('HT') ])),
E('th', { 'class': 'th' }, E('span', { 'data-tooltip': _('Very High Throughput') }, [ _('VHT') ])),
E('th', { 'class': 'th' }, _('Signal'))
])
]);
let clients = [];
let clientData = Object.entries(ap[1]);
for (let i = 0; i < clientData.length; i++) {
if (typeof clientData[i][1] === 'object') {
clients.push([
dawn.getHostnameFromMAC(hostHintsData ,clientData[i][0]),
dawn.getAvailableText(clientData[i][1].ht),
dawn.getAvailableText(clientData[i][1].vht),
clientData[i][1].signal
]);
}
}
cbi_update_table(client_table[ap[0]], clients, E('em', _('No clients connected.')));
return [
ap[1].hostname,
ap[1].iface,
ap[0],
dawn.getFormattedNumber(ap[1].channel_utilization, 2, 2.55) + '%',
dawn.getFormattedNumber(ap[1].freq, 3, 1000) + ' GHz (' + _('Channel') + ': ' + dawn.getChannelFromFrequency(ap[1].freq) + ')',
ap[1].num_sta,
dawn.getAvailableText(ap[1].ht_support),
dawn.getAvailableText(ap[1].vht_support),
ap[1].num_sta > 0 ? client_table[ap[0]] : E('em', { 'style': 'display: inline' }, _('No clients connected.'))
]
});
cbi_update_table(ap_table, aps, E('em', _('No access points available.')));
body.appendChild(ap_table);
}
return body;
}
});

View file

@ -1,10 +0,0 @@
module("luci.controller.dawn", package.seeall)
function index()
local e = entry({ "admin", "dawn" }, firstchild(), "DAWN", 60)
e.dependent = false
e.acl_depends = { "luci-app-dawn" }
entry({ "admin", "dawn", "view_network" }, cbi("dawn/dawn_network"), "View Network Overview", 1)
entry({ "admin", "dawn", "view_hearing_map" }, cbi("dawn/dawn_hearing_map"), "View Hearing Map", 2)
end

View file

@ -1,70 +0,0 @@
m = Map("dawn", "Hearing Map", translate("Hearing Map"))
m.pageaction = false
s = m:section(NamedSection, "__hearingmap__")
function s.render(self, sid)
local tpl = require "luci.template"
tpl.render_string([[
<%
local utl = require "luci.util"
local xml = require "luci.xml"
local status = require "luci.tools.ieee80211"
local stat = utl.ubus("dawn", "get_hearing_map", { })
local name, macs
for name, macs in pairs(stat) do
%>
<div class="cbi-section-node">
<h3>SSID: <%= xml.pcdata(name) %></h3>
<table class="table" id="dawn_hearing_map">
<tr class="tr table-titles">
<th class="th">Client MAC</th>
<th class="th">AP MAC</th>
<th class="th">Frequency</th>
<th class="th">HT Sup</th>
<th class="th">VHT Sup</th>
<th class="th">Signal</th>
<th class="th">RCPI</th>
<th class="th">RSNI</th>
<th class="th">Channel Utilization</th>
<th class="th">Station connect to AP</th>
<th class="th">Score</th>
</tr>
<%
local mac, data
for mac, data in pairs(macs) do
local mac2, data2
local count_loop = 0
for mac2, data2 in pairs(data) do
if data2.freq ~= 0 then --prevent empty entry crashes
%>
<tr class="tr">
<td class="td"><%= (count_loop == 0) and mac or "" %></td>
<td class="td"><%= mac2 %></td>
<td class="td"><%= "%.3f" %( data2.freq / 1000 ) %> GHz Channel: <%= "%d" %( status.frequency_to_channel(data2.freq) ) %></td>
<td class="td"><%= (data2.ht_capabilities == true and data2.ht_support == true) and "True" or "False" %></td>
<td class="td"><%= (data2.vht_capabilities == true and data2.vht_support == true) and "True" or "False" %></td>
<td class="td"><%= "%d" % data2.signal %></td>
<td class="td"><%= "%d" % data2.rcpi %></td>
<td class="td"><%= "%d" % data2.rsni %></td>
<td class="td"><%= "%.2f" % (data2.channel_utilization / 2.55) %> %</td>
<td class="td"><%= "%d" % data2.num_sta %></td>
<td class="td"><%= "%d" % data2.score %></td>
</tr>
<%
count_loop = count_loop + 1
end
end
end
%>
</table>
</div>
<%
end
%>
]])
end
return m

View file

@ -1,94 +0,0 @@
m = Map("dawn", "Network Overview", translate("Network Overview"))
m.pageaction = false
s = m:section(NamedSection, "__networkoverview__")
function s.render(self, sid)
local tpl = require "luci.template"
local json = require "luci.json"
local utl = require "luci.util"
tpl.render_string([[
<%
local status = require "luci.tools.ieee80211"
local utl = require "luci.util"
local sys = require "luci.sys"
local xml = require "luci.xml"
local hosts = sys.net.host_hints()
local stat = utl.ubus("dawn", "get_network", { })
local name, macs
for name, macs in pairs(stat) do
%>
<div class="cbi-section-node">
<h3>SSID: <%= xml.pcdata(name) %></h3>
<table class="table" id="network_overview_main">
<tr class="tr table-titles">
<th class="th">AP</th>
<th class="th">Clients</th>
</tr>
<%
local mac, data
for mac, data in pairs(macs) do
%>
<tr class="tr">
<td class="td" style="vertical-align: top;">
<table class="table" id="ap-<%= mac %>">
<tr class="tr table-titles">
<th class="th">Hostname</th>
<th class="th">Interface</th>
<th class="th">MAC</th>
<th class="th">Utilization</th>
<th class="th">Frequency</th>
<th class="th">Stations</th>
<th class="th">HT Sup</th>
<th class="th">VHT Sup</th>
</tr>
<tr class="tr">
<td class="td"><%= xml.pcdata(data.hostname) %></td>
<td class="td"><%= xml.pcdata(data.iface) %></td>
<td class="td"><%= mac %></td>
<td class="td"><%= "%.2f" %(data.channel_utilization / 2.55) %> %</td>
<td class="td"><%= "%.3f" %( data.freq / 1000 ) %> GHz (Channel: <%= "%d" %( status.frequency_to_channel(data.freq) ) %>)</td>
<td class="td"><%= "%d" % data.num_sta %></td>
<td class="td"><%= (data.ht_support == true) and "available" or "not available" %></td>
<td class="td"><%= (data.vht_support == true) and "available" or "not available" %></td>
</tr>
</table>
</td>
<td class="td" style="vertical-align: top;">
<table class="table" id="clients-<%= mac %>">
<tr class="tr table-titles">
<th class="th">MAC</th>
<th class="th">HT</th>
<th class="th">VHT</th>
<th class="th">Signal</th>
</tr>
<%
local mac2, data2
for clientmac, clientvals in pairs(data) do
if (type(clientvals) == "table") then
%>
<tr class="tr">
<td class="td"><%= clientmac %></td>
<td class="td"><%= (clientvals.ht == true) and "available" or "not available" %></td>
<td class="td"><%= (clientvals.vht == true) and "available" or "not available" %></td>
<td class="td"><%= "%d" % clientvals.signal %></td>
</tr>
<%
end
end
%>
</table>
</td>
</tr>
<%
end
%>
</table>
</div>
<%
end
%>
]])
end
return m

View file

@ -1,20 +0,0 @@
module("luci.tools.ieee80211", package.seeall)
function frequency_to_channel(freq)
if (freq <= 2400) then
return 0;
elseif (freq == 2484) then
return 14;
elseif (freq < 2484) then
return (freq - 2407) / 5;
elseif (freq >= 4910 and freq <= 4980) then
return (freq - 4000) / 5;
elseif (freq <= 45000) then
return (freq - 5000) / 5;
elseif (freq >= 58320 and freq <= 64800) then
return (freq - 56160) / 2160;
else
return 0;
end
end

View file

@ -0,0 +1,30 @@
{
"admin/dawn/": {
"title": "DAWN",
"order": 60,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-dawn" ]
}
},
"admin/dawn/network_overview": {
"title": "Network Overview",
"order": 1,
"action": {
"type": "view",
"path": "dawn/network_overview"
}
},
"admin/dawn/hearing_map": {
"title": "Hearing Map",
"order": 2,
"action": {
"type": "view",
"path": "dawn/hearing_map"
}
}
}

View file

@ -2,7 +2,11 @@
"luci-app-dawn": {
"description": "Grant UCI access for luci-app-dawn",
"read": {
"uci": [ "dawn" ]
"uci": [ "dawn" ],
"ubus": {
"dawn": [ "get_network", "get_hearing_map" ],
"luci-rpc": [ "getHostHints" ]
}
},
"write": {
"uci": [ "dawn" ]