🎁 Sync 2026-10-18 22:41:16

This commit is contained in:
github-actions[bot] committed 2026-10-18 22:41:16 +08:00
1 parent 3eca35a513
commit 3159b4f938
88 files changed
+5597 -1051

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Status Pages
LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket
PKG_RELEASE:=33
PKG_RELEASE:=34
PKG_BUILD_DEPENDS:=iwinfo
PKG_LICENSE:=Apache-2.0
@@ -5,6 +5,8 @@
var callMountPoints = rpc.declare({
object: 'luci',
method: 'getMountPoints',
params: [ 'root_only' ],
nobatch: true,
expect: { result: [] }
});
@@ -26,7 +28,7 @@ return baseclass.extend({
load: function() {
return Promise.all([
L.resolveDefault(callMountPoints(), {}),
L.resolveDefault(callMountPoints(true), []),
]);
},
@@ -9,6 +9,7 @@
const callIwinfoAssoclistCompat = rpc.declare({
object: 'iwinfo',
nobatch: true,
method: 'assoclist',
params: [ 'device' ],
expect: { results: [] }
@@ -16,6 +17,7 @@ const callIwinfoAssoclistCompat = rpc.declare({
const callIwinfoInfoCompat = rpc.declare({
object: 'iwinfo',
nobatch: true,
method: 'info',
params: [ 'device' ],
expect: {}
@@ -23,6 +25,7 @@ const callIwinfoInfoCompat = rpc.declare({
const callIwinfoDevices = rpc.declare({
object: 'iwinfo',
nobatch: true,
method: 'devices',
expect: { devices: [] }
});
@@ -31,6 +34,27 @@ function isQcaWifiHwtype(hwtype) {
return (hwtype == 'qcawifi' || hwtype == 'qcawificfg80211');
}
function isConfigWifiHwtype(hwtype) {
return isQcaWifiHwtype(hwtype) || ['mtwifi', 'mt_dbdc', 'mtdbdc', 'ralink'].includes(hwtype);
}
function getConfiguredIfname(iface) {
if (iface.ifname)
return iface.ifname;
const hwtype = uci.get('wireless', iface.device, 'type');
if (isQcaWifiHwtype(hwtype))
return getQcaFallbackIfname(iface.device, iface['.name']);
// Only use real VIF names, never invent a primary interface for a VAP.
if (isConfigWifiHwtype(hwtype) && /^(ra[xiyez]?|apcli[xiyez]?)\d+$/.test(iface['.name']))
return iface['.name'];
return null;
}
function useConfiguredWifi() {
const radios = uci.sections('wireless', 'wifi-device');
return radios.length > 0 && radios.every((radio) => isConfigWifiHwtype(radio.type));
}
function pushUnique(list, value) {
if (value && list.indexOf(value) < 0)
list.push(value);
@@ -62,9 +86,9 @@ function getResolvedIwinfoDeviceName(net, deviceLookup) {
const device = net.getWifiDeviceName();
const fallback = getQcaFallbackIfname(device, section);
const configuredIfname = uci.get('wireless', section, 'ifname');
const isQca = isQcaWifiHwtype(uci.get('wireless', device, 'type'));
const isConfig = isConfigWifiHwtype(uci.get('wireless', device, 'type'));
for (const candidate of [ configuredIfname, ifname, section, fallback, isQca ? null : device ])
for (const candidate of [ configuredIfname, ifname, section, fallback, isConfig ? null : device ])
if (candidate && deviceLookup[candidate])
return candidate;
@@ -110,7 +134,7 @@ function buildIwinfoResolver(radios, networks, devices) {
radios.forEach((radio) => {
const name = radio.getName();
if (uci.get('wireless', name, 'disabled') == '1' ||
isQcaWifiHwtype(uci.get('wireless', name, 'type')))
isConfigWifiHwtype(uci.get('wireless', name, 'type')))
return;
const target = radioTargets[name] || (deviceLookup[name] ? name : null);
@@ -613,18 +637,15 @@ return baseclass.extend({
}),
loadIwinfoResolver(radios, networks) {
// Query enabled QCA VAPs only: probing a bare wifiN may create a
// temporary VAP and block rpcd while the disabled radio starts up.
const configuredRadios = uci.sections('wireless', 'wifi-device');
const configOnly = configuredRadios.length > 0 &&
configuredRadios.every((radio) => isQcaWifiHwtype(radio.type));
// Vendor radios are queried through configured VIFs, not global probes.
const configOnly = useConfiguredWifi();
const configuredDevices = [];
for (const iface of uci.sections('wireless', 'wifi-iface')) {
if (iface.disabled == '1' || uci.get('wireless', iface.device, 'disabled') == '1')
continue;
pushUnique(configuredDevices, iface.ifname || getQcaFallbackIfname(iface.device, iface['.name']));
pushUnique(configuredDevices, getConfiguredIfname(iface));
}
return (configOnly ? Promise.resolve(configuredDevices) : L.resolveDefault(callIwinfoDevices(), [])).then((devices) => {
@@ -662,6 +683,16 @@ return baseclass.extend({
return Promise.resolve([]);
const hwtype = uci.get('wireless', net.getWifiDeviceName(), 'type');
if (isConfigWifiHwtype(hwtype)) {
const target = this.iwinfoResolver?.aliasMap[net.getName()];
if (!target)
return Promise.resolve([]);
return L.resolveDefault(callIwinfoAssoclistCompat(target), []).then((entries) => {
if (entries.length || !isQcaWifiHwtype(hwtype) || net.getMode() != 'ap')
return entries;
return callWlanconfigAssoclistCompat(target);
});
}
const candidates = getAssocListCandidates(net, this.iwinfoResolver);
const resolvedIfname = candidates[0];
const useResolvedIfname = (isQcaWifiHwtype(hwtype) && resolvedIfname && resolvedIfname != net.getIfname());
@@ -765,6 +796,8 @@ return baseclass.extend({
},
renderbox(radio, networks) {
const radioUp = radio.isUp() ||
(uci.get('wireless', radio.getName(), 'disabled') != '1' && this.iwinfoInfoMap?.[radio.getName()]?.mode);
let chan = null;
let freq = null;
let rate = null;
@@ -841,7 +874,7 @@ return baseclass.extend({
}
return E('div', { class: 'ifacebox' }, [
E('div', { class: 'ifacebox-head center ' + (radio.isUp() ? 'active' : '') },
E('div', { class: 'ifacebox-head center ' + (radioUp ? 'active' : '') },
E('strong', radio.getName())),
E('div', { class: 'ifacebox-body left' }, [
L.itemlist(E('span'), [
@@ -860,15 +893,14 @@ return baseclass.extend({
isWPSEnabled: {},
load() {
return Promise.all([
network.getWifiDevices(),
network.getWifiNetworks(),
return L.resolveDefault(uci.load('wireless')).then(() => Promise.all([
useConfiguredWifi() ? network.getWifiDevicesFromConfig() : network.getWifiDevices(),
useConfiguredWifi() ? network.getWifiNetworksFromConfig() : network.getWifiNetworks(),
network.getHostHints(),
this.callSessionAccess('access-group', 'luci-mod-status-index-wifi', 'read'),
this.callSessionAccess('access-group', 'luci-mod-status-index-wifi', 'write'),
firewall.getZones(),
L.hasSystemFeature('wifi') ? L.resolveDefault(uci.load('wireless')) : L.resolveDefault(),
]).then(L.bind(data => {
])).then(L.bind(data => {
const tasks = [];
const radios_networks_hints = data[1];
const hasWPS = L.hasSystemFeature('hostapd', 'wps');
@@ -901,6 +933,23 @@ return baseclass.extend({
}, this)));
return Promise.all(tasks).then(() => {
if (useConfiguredWifi()) {
const states = {};
for (const radio of data[0]) {
const name = radio.getName();
const info = this.iwinfoInfoMap[name];
states[name] = { iwinfo: info || {}, up: !!info?.mode && uci.get('wireless', name, 'disabled') != '1' };
}
data[0] = data[0].map((radio) => network.instantiateWifiDevice(radio.getName(), states[radio.getName()]));
data[1] = data[1].map((net) => {
const target = resolver.aliasMap[net.getName()];
const live = network.instantiateWifiNetwork(net.getName(), net.getWifiDeviceName(),
states[net.getWifiDeviceName()], net.getID(), { ifname: target, iwinfo: this.iwinfoInfoMap[target] || {} });
for (const key of ['assoclist', 'isWPSEnabled', 'wps_status'])
live[key] = net[key];
return live;
});
}
return data;
});
}, this));
@@ -28,6 +28,9 @@ return view.extend({
this._include = include;
this._loading = null;
if (include != null)
this.disableCache = include.disableCache;
if (include != null && include.title != null)
this.title = include.title;
@@ -150,16 +153,20 @@ return view.extend({
if (include.hide && !first_load)
return Promise.resolve(null);
if (!include.hide && !container.hasAttribute('data-cached'))
if (include._pendingLoad)
return include._pendingLoad;
if (first_load && !include.hide && !container.hasAttribute('data-cached'))
this.renderIncludePlaceholder(container);
if (typeof(include.load) == 'function') {
return include.load().then(L.bind(function(result) {
include._pendingLoad = Promise.resolve().then(function() { return include.load(); }).then(L.bind(function(result) {
this.applyIncludeResult(include, container, result, first_load);
return result;
}, this)).catch(function() {
return null;
});
}).finally(function() { include._pendingLoad = null; });
return include._pendingLoad;
}
this.applyIncludeResult(include, container, null, first_load);
@@ -188,43 +195,13 @@ return view.extend({
}
},
invokeIncludesLoad: function(includes, first_load) {
var tasks = [];
for (var i = 0; i < includes.length; i++) {
if (includes[i].hide && !first_load) {
tasks.push(Promise.resolve(null));
continue;
}
if (typeof(includes[i].load) == 'function') {
tasks.push(Promise.resolve(includes[i].load()).catch(function() {
return null;
}));
}
else {
tasks.push(Promise.resolve(null));
}
}
return Promise.all(tasks);
},
poll_status: function(includes, containers, first_load) {
var fetch = first_load ? Promise.resolve(null) : network.flushCache();
return fetch.then(L.bind(
this.invokeIncludesLoad, this, includes, first_load
)).then(L.bind(function(results) {
for (var i = 0; i < includes.length; i++) {
this.applyIncludeResult(includes[i], containers[i], results ? results[i] : null, first_load);
}
var ssi = document.querySelector('div.includes');
if (ssi) {
ssi.style.display = '';
ssi.classList.add('fade-in');
}
return fetch.then(L.bind(function() {
// Slow wireless queries must not hold up other cards or their next poll.
this.loadIncludeGroup(includes, containers, true, first_load);
return this.loadIncludeGroup(includes, containers, false, first_load);
}, this));
},
+12
View File
@@ -37,3 +37,15 @@ mtwifi/mt_dbdc/ralink radios. It does not start a repeating scan poll or stop
LuCI's global poller. The refresh button is enabled immediately on completion or failure;
requests are coalesced per radio and serialized across radios. Run
`node modules/luci-mod-status/tests/channel-analysis.js` from the LuCI root.
Run `node modules/luci-mod-status/tests/overview-async.js` to check that a
pending wireless query does not delay other overview cards or their next
refresh, duplicate itself, or prevent retry after failure. Run
`node modules/luci-mod-status/tests/wifi-disabled.js` for configured QCA and
MediaTek VIF selection, disabled interfaces and mixed-backend handling.
Run `node modules/luci-mod-status/tests/mount-points.js` to verify that the
homepage's root-only storage query never calls statvfs on unrelated mounts,
while existing callers without the option still receive the complete list.
Cold-boot validation should keep a FAT USB volume mounted: its initial free-space
scan must not delay the overview's system, memory or storage cards.
+25
View File
@@ -0,0 +1,25 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const source = fs.readFileSync(path.join(__dirname, '../../luci-base/root/usr/share/rpcd/ucode/luci'), 'utf8');
const method = source.slice(source.indexOf('\tgetMountPoints: {'), source.indexOf('\tgetRealtimeStats: {'));
let visited = [], closed = false;
const lines = ['/dev/root /rom squashfs ro 0 0', '/dev/ubi0_5 /overlay ubifs rw 0 0',
'overlayfs:/overlay / overlay rw 0 0', '/dev/sda1 /mnt/USB\\040Disk vfat rw 0 0'];
const api = new Function('open', 'split', 'replace', 'chr', 'int', 'statvfs', 'length', 'push',
'return ({' + method + '}).getMountPoints;')(
() => { let i = 0; return {read: () => lines[i++] || '', close: () => {closed = true;}}; },
(s, sep) => s.split(sep), (s, re, fn) => s.replace(re, fn), String.fromCharCode, parseInt,
mount => {visited.push(mount); return {blocks: 100, bsize: 4096, bavail: 20, bfree: 25};},
s => s.length, (a, v) => a.push(v));
const roots = api.call({args: {root_only: true}}).result;
assert.deepEqual(visited, ['/overlay', '/'], 'overview must not stat unrelated filesystems');
assert.deepEqual(roots.map(m => m.mount), visited);
assert.equal(roots[0].size, 409600);
assert(closed);
visited = []; closed = false;
const all = api.call({args: {}}).result;
assert.deepEqual(visited, ['/rom', '/overlay', '/', '/mnt/USB Disk']);
assert.equal(all.length, 4, 'existing callers must retain all mounts');
assert(closed);
console.log('Root-only storage query and backward-compatible full mount listing passed');
+29
View File
@@ -0,0 +1,29 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const source = fs.readFileSync(path.join(__dirname, '../htdocs/luci-static/resources/view/status/index.js'), 'utf8');
const view = new Function('view', 'network', 'L', source)(
{extend: o => o}, {flushCache: () => Promise.resolve()},
{bind: (fn, ctx, ...args) => fn.bind(ctx, ...args)});
(async () => {
let release, queries = 0;
const results = [];
const slow = {deferFirstLoad: true, load: () => { queries++; return new Promise(r => {release = r;}); }};
const fast = {load: () => Promise.resolve('system ready')};
const container = {hasAttribute: () => true};
view.applyIncludeResult = (include, el, result) => results.push(result);
await view.poll_status([slow, fast], [container, container], false);
assert.deepEqual(results, ['system ready'], 'slow wireless must not block system rendering');
await view.poll_status([slow, fast], [container, container], false);
assert.equal(queries, 1, 'polls must not duplicate an outstanding wireless query');
assert.equal(results.length, 2, 'system must continue refreshing while wireless is pending');
release('wireless ready');
await slow._pendingLoad;
assert.equal(results[2], 'wireless ready');
assert.equal(slow._pendingLoad, null);
slow.load = () => Promise.reject(new Error('radio not ready'));
await view.poll_status([slow, fast], [container, container], false);
await slow._pendingLoad;
assert.equal(slow._pendingLoad, null, 'failed wireless query must be retried on a later poll');
console.log('Overview asynchronous rendering, in-flight coalescing and failure retry passed');
})().catch(e => {console.error(e); process.exit(1);});
+21
View File
@@ -61,5 +61,26 @@ function networks() { return uci.sections('wireless', 'wifi-iface').map(s => ({
config.wifi0.disabled = '0';
resolver = await view.loadIwinfoResolver(radios(), networks());
assert.deepEqual(resolver.queryTargets, ['radio0'], 'mixed setups must not probe bare QCA radios');
for (const type of ['mtwifi', 'mt_dbdc', 'mtdbdc', 'ralink']) {
config = {
ra: {'.type':'wifi-device', type}, rai: {'.type':'wifi-device', type},
ra0: {'.type':'wifi-iface', device:'ra', ifname:'ra0'},
cfg5g: {'.type':'wifi-iface', device:'rai', ifname:'rai0'},
rai1: {'.type':'wifi-iface', device:'rai', disabled:'1'}
};
calls = [];
resolver = await view.loadIwinfoResolver(radios(), networks());
assert.deepEqual(resolver.queryTargets, ['ra0', 'rai0']);
assert.equal(resolver.aliasMap.cfg5g, 'rai0');
view.iwinfoResolver = resolver;
await view.loadIwinfoInfoMap(resolver);
for (const net of networks()) await view.getAssocListForNetwork(net);
assert.deepEqual(calls, [['info','ra0'], ['info','rai0'], ['assoclist','ra0'], ['assoclist','rai0']],
type + ': query only enabled real VIFs, without enumeration or synthetic netids');
delete config.ra0.ifname;
config.cfg5g.disabled = '1';
resolver = await view.loadIwinfoResolver(radios(), networks());
assert.deepEqual(resolver.queryTargets, ['ra0'], 'legacy section name is a valid VIF');
}
console.log('Wi-Fi status: disabled radios/VAPs, active VAP, custom ifname, missing VAP and mixed backends passed');
})().catch(e => {console.error(e); process.exit(1);});