🌴 Sync 2026-11-10 18:25:40

This commit is contained in:
github-actions[bot] committed 2026-11-10 18:25:40 +08:00
1 parent 2d7e7f3abc
commit ad8b8b0fd1
39 files changed
+1297 -450

No files matched your search

@@ -1,5 +1,7 @@
// SPDX-License-Identifier: MIT
'use strict';
/* global feedback */
'require device-manager.feedback as feedback';
'require baseclass';
'require ui';
'require device-manager.model as model';
@@ -252,7 +254,7 @@ return baseclass.extend({
})
.catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', [ _('Clear failed: %s').format(err.message || err) ]), 'danger');
feedback.notify(_('Clear failed: %s').format(err.message || err), 'danger');
}).finally(function() {
button.classList.remove('spinning');
button.disabled = false;
@@ -265,6 +267,7 @@ return baseclass.extend({
showDetailModal: function(dev) {
const self = this;
const hero = deviceHero(dev);
const addresses = key => (dev[key + 'Addresses'] || []).length
? dev[key + 'Addresses'].join('\n') : dev[key];
const pingStatus = E('strong', { 'class': 'dm-ping-status', 'role': 'status', 'aria-live': 'polite' }, [ _('Probing…') ]);
@@ -284,6 +287,10 @@ return baseclass.extend({
pingDetail.textContent = _('Sending one Ping from the router to this device.');
pingLog.style.display = 'none';
pending = Promise.resolve().then(() => self.handlePingDevice(dev)).then(reply => {
const status = hero.querySelector('.dm-dialog-status');
for (const value of [ 'online', 'offline', 'unknown' ]) status.classList.remove('dm-dialog-status-' + value);
status.classList.add('dm-dialog-status-' + dev.status);
status.textContent = statusLabel(dev.status);
pingStatus.textContent = reply.reachable ? _('Online · Ping replied') : _('No Ping reply');
pingStatus.classList.add(reply.reachable ? 'dm-ping-success' : 'dm-ping-warning');
pingDetail.textContent = _('Probe address: %s').format(reply.ip + (reply.interface ? ' (' + reply.interface + ')' : '')) +
@@ -311,7 +318,7 @@ return baseclass.extend({
const neighbors = (dev.neighbors || []).map(neighbor => [ neighbor.ip, neighbor.dev, neighbor.state ].filter(Boolean).join(' · ')).join('\n');
ui.showModal(_('Device details'), [ E('div', { 'class': 'dm-dialog' }, [
closeButton(),
deviceHero(dev),
hero,
E('section', { 'class': 'dm-ping-card' }, [
E('div', { 'class': 'dm-ping-heading' }, [ pingStatus, pingButton ]), pingDetail, pingLog
]),
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
'require ui';
const TIMEOUT = 12000;
return baseclass.extend({
createWarning: function() {
let previous = '', timer = null;
const message = E('span', { 'class': 'dm-warning-message' });
const dismiss = () => {
clearTimeout(timer);
timer = null;
node.style.display = 'none';
};
const node = E('div', {
'id': 'dm-source-warning', 'class': 'alert-message warning dm-source-warning',
'role': 'status', 'style': 'display:none;'
}, [ message, E('button', {
'type': 'button', 'class': 'btn cbi-button dm-warning-close',
'click': dismiss
}, [ _('Close') ]) ]);
return {
node: node,
destroy: dismiss,
update: errors => {
const text = errors.length
? _('Some device data could not be read. Unconfirmed devices are shown as unknown: %s').format(errors.join('; ')) : '';
if (text === previous) return;
previous = text;
dismiss();
message.textContent = text;
if (text) {
node.style.display = 'flex';
timer = setTimeout(dismiss, TIMEOUT);
}
}
};
},
notify: function(message, level) {
const node = ui.addNotification(null, E('p', {}, [ message ]), level);
if (node) setTimeout(() => node.remove(), TIMEOUT);
}
});
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: MIT
'use strict';
/* global feedback */
'require device-manager.feedback as feedback';
'require baseclass';
'require ui';
'require device-manager.model as model';
@@ -226,7 +228,7 @@ return baseclass.extend({
})
.catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', [ _('Could not delete group: %s').format(err.message || err) ]), 'danger');
feedback.notify(_('Could not delete group: %s').format(err.message || err), 'danger');
}).finally(function() {
button.classList.remove('spinning');
button.disabled = false;
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
const MAX_DEVICES = 16;
const MAX_DURATION = 12000;
return baseclass.extend({
run: function(devices, ping, isCurrent) {
// ping_device has a shared router lock, so check one device at a time.
const task = (this.pending || Promise.resolve()).then(() => {
const targets = devices.filter(device => device.status === 'unknown' && (device.ipv4 || device.ipv6));
const started = Date.now(), limit = Math.min(targets.length, MAX_DEVICES);
let count = 0;
const next = () => {
if (!isCurrent() || count >= limit || Date.now() - started >= MAX_DURATION) return;
const index = (this.cursor || 0) % targets.length;
this.cursor = index + 1;
count++;
const device = targets[index];
return Promise.resolve().then(() => {
if (isCurrent() && device.status === 'unknown') return ping(device);
}).then(next, next);
};
return next();
});
this.pending = task.then(() => {}, () => {});
return task;
}
});
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: MIT
'use strict';
/* global snapshotCache */
'require baseclass';
'require rpc';
'require uci';
'require fs';
'require device-manager.model as model';
'require device-manager.snapshot-cache as snapshotCache';
const CONFIG = 'device_manager';
const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', reject: true });
@@ -13,6 +15,7 @@ const callNeighbors = rpc.declare({ object: 'luci.device-manager', method: 'get_
const callScan = rpc.declare({ object: 'luci.device-manager', method: 'scan_devices', reject: true });
const callPing = rpc.declare({ object: 'luci.device-manager', method: 'ping_device', params: [ 'mac' ], reject: true });
const callWireless = rpc.declare({ object: 'luci-rpc', method: 'getWirelessDevices', reject: true });
const callNetworkDevices = rpc.declare({ object: 'luci-rpc', method: 'getNetworkDevices', reject: true });
const callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], reject: true });
const callCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
const callRevert = rpc.declare({ object: 'uci', method: 'revert', params: [ 'config' ], reject: true });
@@ -28,7 +31,7 @@ function readSource(name, read) {
}
function wifiStations() {
return callWireless().then(requireObject).then(radios => {
return Promise.all([ callWireless().then(requireObject), callNetworkDevices().then(requireObject) ]).then(([ radios, devices ]) => {
const interfaces = new Set();
for (const radio of Object.values(radios)) {
for (const iface of radio.interfaces || []) {
@@ -38,7 +41,9 @@ function wifiStations() {
for (const vlan of iface.vlans || []) if (vlan.ifname) interfaces.add(vlan.ifname);
}
}
return Promise.all(Array.from(interfaces).map(device => readSource(device, () => callAssoc(device).then(reply => {
// netifd may retain an ifname after the actual wireless device has disappeared.
const present = Array.from(interfaces).filter(device => Object.prototype.hasOwnProperty.call(devices, device));
return Promise.all(present.map(device => readSource(device, () => callAssoc(device).then(reply => {
if (!Array.isArray(requireObject(reply).results)) throw new Error(_('Invalid discovery response'));
return reply.results.map(station => ({ mac: station.mac, dev: device }));
})))).then(results => ({
@@ -75,7 +80,7 @@ function requireGroup(id) {
return baseclass.extend({
scanDevices: function() {
if (L.hasViewPermission() !== true) return Promise.reject(new Error(_('Read-only access')));
return this.queue(() => callScan().then(reply => {
return this.queue(() => snapshotCache.clear().then(() => callScan()).then(reply => {
if (requireObject(reply).ok !== true)
throw new Error(_('LAN scanning is unavailable. Check the selected interfaces and probe dependencies.'));
}));
@@ -136,10 +141,16 @@ return baseclass.extend({
data.errors.push(...result.value.errors);
} else data[result.name] = result.value;
}
return data;
return snapshotCache.save(data).then(() => data);
});
}));
},
loadCached: function(cacheOnly) {
return snapshotCache.load().then(entry => entry
? { snapshot: entry.snapshot, cached: true }
: cacheOnly ? { snapshot: null, cached: false }
: this.load().then(snapshot => ({ snapshot: snapshot, cached: false })));
},
recoverPending: function() {
if (!this.rollbackPending) return Promise.resolve();
return callRevert(CONFIG).then(() => { this.rollbackPending = false; });
@@ -148,7 +159,7 @@ return baseclass.extend({
return this.queue(() => {
if (L.hasViewPermission() !== true) throw new Error(_('Read-only access'));
let staged = false;
return this.recoverPending().then(reloadConfig).then(change).then(() => {
return this.recoverPending().then(() => snapshotCache.clear()).then(reloadConfig).then(change).then(() => {
staged = true;
return uci.save();
}).then(() => callCommit(CONFIG)).catch(error => {
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
'require rpc';
const MAX_AGE = 5 * 60 * 1000;
// Keep device data within this tab and the current LuCI session and access level.
function key() {
return 'luci-device-manager-snapshot-v1:' + rpc.getSessionID() + ':' + (L.hasViewPermission() === true ? 'write' : 'read');
}
return baseclass.extend({
load: function() {
return Promise.resolve().then(() => window.sessionStorage.getItem(key())).then(raw => {
const entry = raw ? JSON.parse(raw) : null;
if (!entry || !Number.isFinite(entry.time) || Date.now() < entry.time || Date.now() - entry.time >= MAX_AGE)
return null;
const data = entry.snapshot;
if (!data || !Array.isArray(data.devices) || !Array.isArray(data.groups) || !Array.isArray(data.errors)
|| typeof data.discoveryComplete !== 'boolean') return null;
return entry;
}).catch(() => null);
},
save: function(snapshot) {
return Promise.resolve().then(() => window.sessionStorage.setItem(key(), JSON.stringify({ time: Date.now(), snapshot: snapshot })))
.catch(() => {});
},
clear: function() {
return Promise.resolve().then(() => window.sessionStorage.removeItem(key())).catch(() => {});
}
});
@@ -35,7 +35,9 @@ body.dark-mode .dm-status-dot-unknown { background-color:#fdd663; box-shadow:0 0
.dm-table .dm-action-link:focus-visible { outline:2px solid currentColor; outline-offset:2px; border-radius:2px; }
.dm-status-tab:focus-visible { outline:2px solid #1a73e8; outline-offset:2px; }
.dm-source-warning { overflow-wrap:anywhere; }
.dm-source-warning { overflow-wrap:anywhere; align-items:flex-start; gap:12px; }
.dm-warning-message { flex:1; min-width:0; }
.dm-warning-close { flex-shrink:0; margin-left:auto; }
.dm-type-th { width: 56px; text-align: center; }
.dm-type-cell { text-align: center; vertical-align: middle; padding: 6px 8px; }
@@ -127,10 +127,23 @@ return baseclass.extend({
renderTable: function() {
if (!this.tableBodyNode) return;
dom.content(this.tableBodyNode, null);
const filtered = this.devices.filter(device =>
(this.activeTab === 'all' || device.status === this.activeTab) &&
model.matchesDevice(device, this.groups, this.activeGroup, this.filterText));
const loading = this.initialLoading || this.listRefreshing || this.checkingOnline || (this.showingCache && !this.cacheRefreshFailed);
if (!filtered.length && (loading || this.loadFailed)) {
this.tableBodyNode.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'colspan': 8, 'style': 'text-align:center; padding:36px 16px;' }, [
E('span', { 'class': loading ? 'spinning' : '', 'role': 'status' }, [
loading ? _('Loading list...') : _('Could not load the device list.')
]),
...(loading ? [] : [ E('button', {
'type': 'button', 'class': 'cbi-button', 'style': 'margin-left:12px;', 'click': () => this.refresh()
}, [ _('Retry') ]) ])
])
]));
return;
}
if (filtered.length === 0) {
let emptyMsg = _('No devices found. Refresh the list to try again.');
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT
'use strict';
/* global deviceDialog, groupDialog, preferences, service, table */
/* global deviceDialog, feedback, groupDialog, onlineCheck, preferences, service, table */
'require view';
'require ui';
'require device-manager.model as model';
@@ -9,6 +9,8 @@
'require device-manager.table as table';
'require device-manager.device-dialog as deviceDialog';
'require device-manager.group-dialog as groupDialog';
'require device-manager.feedback as feedback';
'require device-manager.online-check as onlineCheck';
return view.extend({
devices: [],
@@ -25,13 +27,14 @@ return view.extend({
handleReset: null,
load: function() {
return Promise.all([ service.load(), preferences.load() ])
.then(data => ({ snapshot: data[0], preferences: data[1] }));
return Promise.all([ service.loadCached(true), preferences.load() ])
.then(data => Object.assign({ preferences: data[1] }, data[0]));
},
acceptData: function(data) {
this.groups = model.parseGroups(data.groups);
this.devices = model.parseDevices(data, this.groups);
this.checkingOnline = !this.showingCache && this.devices.some(device => device.status === 'unknown' && (device.ipv4 || device.ipv6));
this.sourceErrors = data.errors || [];
if (this.activeGroup !== 'all' && this.activeGroup !== 'ungrouped' && !this.getGroupById(this.activeGroup)) {
this.activeGroup = 'all';
@@ -43,10 +46,21 @@ return view.extend({
const self = this;
this.readonly = L.hasViewPermission() !== true;
this.acceptData(data.snapshot);
this.hasSnapshot = Boolean(data.snapshot);
this.initialLoading = !this.hasSnapshot;
this.loadFailed = false;
this.listRefreshing = false;
this.checkingOnline = false;
this.showingCache = Boolean(data.cached);
this.cacheRefreshFailed = false;
if (this.sourceWarning) this.sourceWarning.destroy();
this.sourceWarning = feedback.createWarning();
if (data.snapshot) this.acceptData(data.snapshot);
else { this.devices = []; this.groups = []; this.sourceErrors = []; }
const savedView = data.preferences || {};
this.activeTab = ['all', 'online', 'offline', 'unknown'].includes(savedView.tab) ? savedView.tab : 'online';
this.activeGroup = (savedView.group === 'all' || savedView.group === 'ungrouped' || this.getGroupById(savedView.group)) ? savedView.group : 'all';
this.activeGroup = this.initialLoading ? (savedView.group || 'all')
: (savedView.group === 'all' || savedView.group === 'ungrouped' || this.getGroupById(savedView.group)) ? savedView.group : 'all';
this.maskInfo = Boolean(savedView.maskInfo);
const sortHeader = (key, label, width, centered) => E('th', {
@@ -87,7 +101,8 @@ return view.extend({
E('h2', {}, [ _('LAN Device Management') ]),
E('div', { 'class': 'cbi-map-descr' }, [ _('Discover network devices and save custom names, remarks and groups by MAC address.') ]),
E('div', { 'id': 'dm-source-warning', 'class': 'alert-message warning dm-source-warning', 'style': 'display:none;' }),
E('div', { 'id': 'dm-cache-status', 'class': 'cbi-map-descr', 'role': 'status', 'style': 'display:none;' }),
this.sourceWarning.node,
// Row 1: Status Tabs
E('div', { 'class': 'dm-status-tabs', 'id': 'dm-tabs-container', 'role': 'tablist', 'aria-label': _('Device status') }),
@@ -120,13 +135,13 @@ return view.extend({
]),
E('div', { 'class': 'dm-btn-group' }, [
E('button', { 'type': 'button',
'class': 'cbi-button cbi-button-neutral',
'disabled': self.readonly || null,
'class': 'cbi-button cbi-button-neutral dm-requires-data',
'disabled': self.readonly || self.initialLoading || null,
'click': function() { self.showGroupModal(); }
}, [ _('Manage groups') ]),
E('button', { 'type': 'button',
'class': 'cbi-button cbi-button-action dm-btn-blue',
'disabled': self.readonly || null,
'class': 'cbi-button cbi-button-action dm-btn-blue dm-requires-data',
'disabled': self.readonly || self.initialLoading || null,
'click': function() { self.showEditModal(null); }
}, [ _('Add device') ]),
E('button', { 'type': 'button',
@@ -183,12 +198,16 @@ return view.extend({
this.tableHeadNode = theadNode;
this.warningNode = viewNode.querySelector('#dm-source-warning');
this.cacheStatusNode = viewNode.querySelector('#dm-cache-status');
this.tabMenuNode = viewNode.querySelector('#dm-tabs-container');
this.tableBodyNode = viewNode.querySelector('#device_manager_tbody');
this.groupSelectNode = viewNode.querySelector('#dm-group-select');
this.maskBtnNode = viewNode.querySelector('#dm-btn-mask');
this.dataActionButtons = viewNode.querySelectorAll('.dm-requires-data');
this.updateView();
clearTimeout(this.loadTimer);
this.loadTimer = setTimeout(() => this.initialLoading || this.showingCache ? this.refresh() : this.confirmUnknownDevices(), 0);
return viewNode;
},
@@ -202,7 +221,30 @@ return view.extend({
updateSortHeaders: function() { return table.updateSortHeaders.call(this); },
showEditModal: function(device) { return deviceDialog.showEditModal.call(this, device); },
showDetailModal: function(device) { return deviceDialog.showDetailModal.call(this, device); },
handlePingDevice: function(device) { return service.pingDevice(device.mac); },
handlePingDevice: function(device) {
return service.pingDevice(device.mac).then(reply => {
// A reply for an older snapshot must not change the current list.
if (reply.reachable && device.status === 'unknown' && this.devices.includes(device)) {
device.status = 'online';
device.statusDetail = _('Online · Ping replied');
this.updateView();
}
return reply;
});
},
confirmUnknownDevices: function() {
const devices = this.devices;
if (this.initialLoading || this.showingCache || this.confirmingDevices === devices) return this.confirming || Promise.resolve();
this.confirmingDevices = devices;
this.confirming = onlineCheck.run(devices, device => this.handlePingDevice(device),
() => this.devices === devices && !this.showingCache).finally(() => {
if (this.devices === devices) {
this.checkingOnline = false;
this.updateView();
}
});
return this.confirming;
},
confirmDelete: function(device) { return deviceDialog.confirmDelete.call(this, device); },
showGroupModal: function() { return groupDialog.showGroupModal.call(this); },
promptRenameGroup: function(group) { return groupDialog.promptRenameGroup.call(this, group); },
@@ -216,33 +258,57 @@ return view.extend({
updateView: function() {
this.renderTabs();
this.renderGroupSelect();
if (this.groupSelectNode) this.groupSelectNode.disabled = this.initialLoading;
for (const button of this.dataActionButtons || []) button.disabled = this.readonly || !this.hasSnapshot;
this.updateSortHeaders();
if (this.maskBtnNode) {
this.maskBtnNode.textContent = this.maskInfo ? _('Show info') : _('Hide info');
}
this.renderTable();
if (this.warningNode) {
this.warningNode.textContent = this.sourceErrors.length
? _('Some device data could not be read. Unconfirmed devices are shown as unknown: %s').format(this.sourceErrors.join('; ')) : '';
this.warningNode.style.display = this.sourceErrors.length ? '' : 'none';
if (this.sourceWarning) this.sourceWarning.update(this.sourceErrors);
if (this.cacheStatusNode) {
this.cacheStatusNode.textContent = this.showingCache && this.cacheRefreshFailed
? _('Showing cached device data. Refresh to retry.') : '';
this.cacheStatusNode.style.display = this.showingCache && this.cacheRefreshFailed ? '' : 'none';
}
},
scan: function() {
if (this.scanning) return this.scanning;
this.scanning = service.scanDevices().then(() => this.refresh()).catch(error => {
ui.addNotification(null, E('p', {}, [ _('Could not scan LAN devices: %s').format(error.message || error) ]), 'danger');
feedback.notify(_('Could not scan LAN devices: %s').format(error.message || error), 'danger');
}).finally(() => { this.scanning = null; });
return this.scanning;
},
refresh: function() {
if (this.refreshing) return this.refreshing;
clearTimeout(this.loadTimer);
this.loadTimer = null;
this.listRefreshing = true;
if (!this.hasSnapshot) {
this.initialLoading = true;
this.loadFailed = false;
}
this.updateView();
this.refreshing = service.load().then(data => {
this.listRefreshing = false;
this.initialLoading = false;
this.loadFailed = false;
this.hasSnapshot = true;
this.showingCache = false;
this.cacheRefreshFailed = false;
this.acceptData(data);
this.updateView();
this.loadTimer = setTimeout(() => this.confirmUnknownDevices(), 0);
}).catch(error => {
ui.addNotification(null, E('p', {}, [ _('Could not refresh the device list: %s').format(error.message || error) ]), 'danger');
this.listRefreshing = false;
this.checkingOnline = false;
this.initialLoading = false;
this.loadFailed = true;
this.cacheRefreshFailed = true;
this.updateView();
feedback.notify(_('Could not refresh the device list: %s').format(error.message || error), 'danger');
}).finally(() => { this.refreshing = null; });
return this.refreshing;
}