mirror of
https://github.com/openwrt/luci.git
synced 2026-04-15 19:01:56 +00:00
luci-app-wifihistory: add WiFi station history
Add a new application that tracks WiFi associated stations over time and displays history of connected devices. A ucode service polls iwinfo assoclist and persists station data to a JSON file. The LuCI view shows connected/disconnected status, MAC, hostname, signal, and timestamps. Closes: #8109 Signed-off-by: Deborah Olaboye <deboraholaboye@gmail.com>
This commit is contained in:
committed by
Paul Donald
parent
7d6784fa59
commit
9c4b2ee8de
11
applications/luci-app-wifihistory/Makefile
Normal file
11
applications/luci-app-wifihistory/Makefile
Normal file
@@ -0,0 +1,11 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LuCI support for WiFi Station History
|
||||
LUCI_DEPENDS:=+luci-base +rpcd-mod-iwinfo +ucode +ucode-mod-fs +ucode-mod-ubus
|
||||
LUCI_DESCRIPTION:=Track and display history of WiFi associated stations
|
||||
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
include ../../luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -0,0 +1,181 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
const callGetHistory = rpc.declare({
|
||||
object: 'luci.wifihistory',
|
||||
method: 'getHistory',
|
||||
expect: { history: {} }
|
||||
});
|
||||
|
||||
const callClearHistory = rpc.declare({
|
||||
object: 'luci.wifihistory',
|
||||
method: 'clearHistory',
|
||||
expect: { result: true }
|
||||
});
|
||||
|
||||
function formatDate(epoch) {
|
||||
if (!epoch || epoch <= 0)
|
||||
return '-';
|
||||
|
||||
return new Date(epoch * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load() {
|
||||
return callGetHistory();
|
||||
},
|
||||
|
||||
updateTable(table, history) {
|
||||
const stations = Object.values(history);
|
||||
|
||||
/* Sort connected stations first, then by most recently seen */
|
||||
stations.sort((a, b) => {
|
||||
if (a.connected !== b.connected)
|
||||
return a.connected ? -1 : 1;
|
||||
|
||||
return (b.last_seen || 0) - (a.last_seen || 0);
|
||||
});
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (const s of stations) {
|
||||
let hint;
|
||||
if (s.hostname && s.ipv4 && s.ipv6)
|
||||
hint = '%s (%s, %s)'.format(s.hostname, s.ipv4, s.ipv6);
|
||||
else if (s.hostname && (s.ipv4 || s.ipv6))
|
||||
hint = '%s (%s)'.format(s.hostname, s.ipv4 || s.ipv6);
|
||||
else if (s.ipv4 || s.ipv6)
|
||||
hint = s.ipv4 || s.ipv6;
|
||||
else
|
||||
hint = '-';
|
||||
|
||||
let sig_value = '-';
|
||||
let sig_title = '';
|
||||
|
||||
if (s.signal && s.signal !== 0) {
|
||||
if (s.noise && s.noise !== 0) {
|
||||
sig_value = '%d/%d\xa0%s'.format(s.signal, s.noise, _('dBm'));
|
||||
sig_title = '%s: %d %s / %s: %d %s / %s %d'.format(
|
||||
_('Signal'), s.signal, _('dBm'),
|
||||
_('Noise'), s.noise, _('dBm'),
|
||||
_('SNR'), s.signal - s.noise);
|
||||
}
|
||||
else {
|
||||
sig_value = '%d\xa0%s'.format(s.signal, _('dBm'));
|
||||
sig_title = '%s: %d %s'.format(_('Signal'), s.signal, _('dBm'));
|
||||
}
|
||||
}
|
||||
|
||||
let icon;
|
||||
if (!s.connected) {
|
||||
icon = L.resource('icons/signal-none.svg');
|
||||
}
|
||||
else {
|
||||
/* Estimate signal quality as percentage:
|
||||
* Map dBm range [-110, -40] to [0%, 100%] */
|
||||
const q = Math.min((s.signal + 110) / 70 * 100, 100);
|
||||
if (q == 0)
|
||||
icon = L.resource('icons/signal-000-000.svg');
|
||||
else if (q < 25)
|
||||
icon = L.resource('icons/signal-000-025.svg');
|
||||
else if (q < 50)
|
||||
icon = L.resource('icons/signal-025-050.svg');
|
||||
else if (q < 75)
|
||||
icon = L.resource('icons/signal-050-075.svg');
|
||||
else
|
||||
icon = L.resource('icons/signal-075-100.svg');
|
||||
}
|
||||
|
||||
rows.push([
|
||||
E('span', {
|
||||
'class': 'ifacebadge',
|
||||
'style': s.connected ? '' : 'opacity:0.5',
|
||||
'title': s.connected ? _('Connected') : _('Disconnected')
|
||||
}, [
|
||||
E('img', { 'src': icon, 'style': 'width:16px;height:16px' }),
|
||||
E('span', {}, [ ' ', s.connected ? _('Yes') : _('No') ])
|
||||
]),
|
||||
s.mac,
|
||||
hint,
|
||||
s.network || '-',
|
||||
E('span', { 'title': sig_title }, sig_value),
|
||||
formatDate(s.first_seen),
|
||||
formatDate(s.last_seen)
|
||||
]);
|
||||
}
|
||||
|
||||
cbi_update_table(table, rows, E('em', _('No station history available')));
|
||||
},
|
||||
|
||||
handleClearHistory(ev) {
|
||||
return ui.showModal(_('Clear Station History'), [
|
||||
E('p', _('This will permanently delete all recorded station history. Are you sure?')),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Cancel')), ' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-negative',
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
return callClearHistory().then(L.bind(function() {
|
||||
ui.hideModal();
|
||||
return callGetHistory().then(L.bind(function(history) {
|
||||
this.updateTable('#wifi_history_table', history);
|
||||
}, this));
|
||||
}, this));
|
||||
})
|
||||
}, _('Clear'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
render(history) {
|
||||
const isReadonlyView = !L.hasViewPermission();
|
||||
|
||||
const v = E([], [
|
||||
E('h2', _('Station History')),
|
||||
E('div', { 'class': 'cbi-map-descr' },
|
||||
_('This page displays a history of all WiFi stations that have connected to this device, including currently connected and previously seen devices.')),
|
||||
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'class': 'right', 'style': 'margin-bottom:1em' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-negative',
|
||||
'disabled': isReadonlyView,
|
||||
'click': ui.createHandlerFn(this, 'handleClearHistory')
|
||||
}, _('Clear History'))
|
||||
]),
|
||||
|
||||
E('table', { 'class': 'table', 'id': 'wifi_history_table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Connected')),
|
||||
E('th', { 'class': 'th' }, _('MAC address')),
|
||||
E('th', { 'class': 'th' }, _('Host')),
|
||||
E('th', { 'class': 'th' }, _('Network')),
|
||||
E('th', { 'class': 'th' }, '%s / %s'.format(_('Signal'), _('Noise'))),
|
||||
E('th', { 'class': 'th' }, _('First seen')),
|
||||
E('th', { 'class': 'th' }, _('Last seen'))
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
this.updateTable(v.querySelector('#wifi_history_table'), history);
|
||||
|
||||
poll.add(L.bind(function() {
|
||||
return callGetHistory().then(L.bind(function(history) {
|
||||
this.updateTable('#wifi_history_table', history);
|
||||
}, this));
|
||||
}, this), 5);
|
||||
|
||||
return v;
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
24
applications/luci-app-wifihistory/root/etc/init.d/wifihistory
Executable file
24
applications/luci-app-wifihistory/root/etc/init.d/wifihistory
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=99
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
|
||||
NAME=wifihistory
|
||||
PROG=/usr/sbin/wifihistory
|
||||
INTERVAL=30
|
||||
|
||||
start_service() {
|
||||
mkdir -p /var/lib/wifihistory
|
||||
|
||||
procd_open_instance "$NAME"
|
||||
procd_set_param command /bin/sh -c "while true; do /usr/bin/ucode $PROG; sleep $INTERVAL; done"
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "wireless"
|
||||
}
|
||||
124
applications/luci-app-wifihistory/root/usr/sbin/wifihistory
Executable file
124
applications/luci-app-wifihistory/root/usr/sbin/wifihistory
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env ucode
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile, open, mkdir, rename } from 'fs';
|
||||
import { connect } from 'ubus';
|
||||
|
||||
const HISTORY_DIR = '/var/lib/wifihistory';
|
||||
const HISTORY_FILE = HISTORY_DIR + '/history.json';
|
||||
const LOCK_FILE = '/var/lock/wifihistory.lock';
|
||||
|
||||
function load_history() {
|
||||
let content = readfile(HISTORY_FILE);
|
||||
if (content == null)
|
||||
return {};
|
||||
|
||||
try {
|
||||
return json(content) || {};
|
||||
}
|
||||
catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function save_history(data) {
|
||||
let tmp = HISTORY_FILE + '.tmp';
|
||||
|
||||
writefile(tmp, sprintf('%J', data));
|
||||
rename(tmp, HISTORY_FILE);
|
||||
}
|
||||
|
||||
function poll_stations() {
|
||||
let ubus = connect();
|
||||
if (!ubus) {
|
||||
warn('Failed to connect to ubus\n');
|
||||
return;
|
||||
}
|
||||
|
||||
let now = time();
|
||||
let history = load_history();
|
||||
let seen_macs = {};
|
||||
|
||||
let wifi_status = ubus.call('network.wireless', 'status');
|
||||
if (!wifi_status)
|
||||
return;
|
||||
|
||||
let hints = ubus.call('luci-rpc', 'getHostHints') || {};
|
||||
|
||||
for (let radio in wifi_status) {
|
||||
let ifaces = wifi_status[radio]?.interfaces;
|
||||
if (!ifaces)
|
||||
continue;
|
||||
|
||||
for (let iface in ifaces) {
|
||||
let ifname = iface?.ifname;
|
||||
if (!ifname)
|
||||
continue;
|
||||
|
||||
let info = ubus.call('iwinfo', 'info', { device: ifname });
|
||||
let ssid = info?.ssid || '';
|
||||
|
||||
let assoc = ubus.call('iwinfo', 'assoclist', { device: ifname });
|
||||
if (!assoc?.results)
|
||||
continue;
|
||||
|
||||
for (let bss in assoc.results) {
|
||||
let mac = bss?.mac;
|
||||
if (!mac)
|
||||
continue;
|
||||
|
||||
mac = uc(mac);
|
||||
seen_macs[mac] = true;
|
||||
|
||||
let hostname = hints?.[mac]?.name || '';
|
||||
let ipv4 = hints?.[mac]?.ipaddrs?.[0] || '';
|
||||
let ipv6 = hints?.[mac]?.ip6addrs?.[0] || '';
|
||||
|
||||
let existing = history[mac];
|
||||
let first_seen = existing?.first_seen || now;
|
||||
|
||||
history[mac] = {
|
||||
mac: mac,
|
||||
hostname: hostname,
|
||||
ipv4: ipv4,
|
||||
ipv6: ipv6,
|
||||
network: ssid,
|
||||
ifname: ifname,
|
||||
connected: true,
|
||||
signal: bss?.signal || 0,
|
||||
noise: bss?.noise || 0,
|
||||
first_seen: first_seen,
|
||||
last_seen: now
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let mac in history)
|
||||
if (!seen_macs[mac])
|
||||
history[mac].connected = false;
|
||||
|
||||
ubus.disconnect();
|
||||
|
||||
save_history(history);
|
||||
}
|
||||
|
||||
mkdir(HISTORY_DIR);
|
||||
|
||||
let lock_fd = open(LOCK_FILE, 'w');
|
||||
if (!lock_fd) {
|
||||
warn('Failed to open lock file\n');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!lock_fd.lock('xn')) {
|
||||
warn('Another instance is already running\n');
|
||||
lock_fd.close();
|
||||
exit(1);
|
||||
}
|
||||
|
||||
poll_stations();
|
||||
|
||||
lock_fd.lock('u');
|
||||
lock_fd.close();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"admin/status/wifihistory": {
|
||||
"title": "Station History",
|
||||
"order": 8,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "status/wifihistory"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-wifihistory" ],
|
||||
"uci": { "wireless": { "@wifi-device": true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"luci-app-wifihistory": {
|
||||
"description": "Grant access to WiFi station history",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"iwinfo": [ "assoclist" ],
|
||||
"luci.wifihistory": [ "getHistory" ]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.wifihistory": [ "clearHistory" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env ucode
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile } from 'fs';
|
||||
|
||||
const HISTORY_FILE = '/var/lib/wifihistory/history.json';
|
||||
|
||||
const methods = {
|
||||
getHistory: {
|
||||
call: function() {
|
||||
let content = readfile(HISTORY_FILE);
|
||||
if (content == null)
|
||||
return { history: {} };
|
||||
|
||||
try {
|
||||
return { history: json(content) || {} };
|
||||
}
|
||||
catch (e) {
|
||||
return { history: {} };
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearHistory: {
|
||||
call: function() {
|
||||
writefile(HISTORY_FILE, '{}');
|
||||
return { result: true };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.wifihistory': methods };
|
||||
Reference in New Issue
Block a user