⛅ Sync 2026-10-11 13:16:00

This commit is contained in:
github-actions[bot] committed 2026-10-11 13:16:00 +08:00
1 parent 705dadd362
commit e6d1b4e2d7
105 files changed
+26511 -50

No files matched your search

@@ -0,0 +1,216 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
'require rpc';
'require uci';
'require fs';
'require device-manager.model as model';
const CONFIG = 'device_manager';
const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', reject: true });
const callLeases = rpc.declare({ object: 'luci-rpc', method: 'getDHCPLeases', reject: true });
const callNeighbors = rpc.declare({ object: 'luci.device-manager', method: 'get_online_status', reject: true });
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 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 });
function requireObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(_('Invalid discovery response'));
return value;
}
function readSource(name, read) {
return Promise.resolve().then(read).then(value => ({ name: name, ok: true, value: value }),
error => ({ name: name, ok: false, error: error.message || String(error) }));
}
function wifiStations() {
return callWireless().then(requireObject).then(radios => {
const interfaces = new Set();
for (const radio of Object.values(radios)) {
for (const iface of radio.interfaces || []) {
const mode = (iface.config || {}).mode;
if (mode && mode !== 'ap' && mode !== 'ap-wds') continue;
if (iface.ifname) interfaces.add(iface.ifname);
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 => {
if (!Array.isArray(requireObject(reply).results)) throw new Error(_('Invalid discovery response'));
return reply.results.map(station => ({ mac: station.mac, dev: device }));
})))).then(results => ({
stations: results.filter(result => result.ok).flatMap(result => result.value),
complete: results.every(result => result.ok),
errors: results.filter(result => !result.ok).map(result => result.name + ': ' + result.error)
}));
});
}
function reloadConfig() {
uci.unload(CONFIG);
return uci.load(CONFIG);
}
function validateText(value, limit) {
const text = model.sanitizeInput(value);
if (text.length > limit) throw new Error(_('Input is too long'));
return text;
}
function validateGroupName(name, exceptId) {
const text = validateText(name, 32);
if (!text) throw new Error(_('Enter a valid group name'));
if (uci.sections(CONFIG, 'group').some(group => group['.name'] !== exceptId && model.sanitizeInput(group.name) === text))
throw new Error(_('A group with this name already exists'));
return text;
}
function requireGroup(id) {
if (!uci.sections(CONFIG, 'group').some(group => group['.name'] === id)) throw new Error(_('This group no longer exists'));
}
return baseclass.extend({
scanDevices: function() {
if (L.hasViewPermission() !== true) return Promise.reject(new Error(_('Read-only access')));
return this.queue(() => callScan().then(reply => {
if (requireObject(reply).ok !== true)
throw new Error(_('LAN scanning is unavailable. Check the selected interfaces and probe dependencies.'));
}));
},
pingDevice: function(mac) {
const normalized = model.normalizeMac(mac);
if (!normalized) return Promise.reject(new Error(_('Enter a valid MAC address')));
return callPing(normalized).then(reply => {
const errors = {
'No known IP address for this device': _('No known IP address for this device'),
'Another device is being probed. Please retry.': _('Another device is being probed. Please retry.'),
'Ping is unavailable': _('Ping is unavailable'),
'Enter a valid MAC address': _('Enter a valid MAC address'),
'Unable to read the kernel neighbor table': _('Unable to read the kernel neighbor table')
};
if (requireObject(reply).ok !== true) {
const error = new Error(errors[reply.error] || _('Ping could not be completed'));
error.output = typeof reply.output === 'string' ? reply.output : '';
throw error;
}
if (typeof reply.reachable !== 'boolean' || typeof reply.ip !== 'string' || typeof reply.output !== 'string')
throw new Error(_('Invalid discovery response'));
return reply;
});
},
// Reads and writes share the queue so a refresh cannot discard an in-flight edit.
queue: function(task) {
const result = (this.pending || Promise.resolve()).then(task);
this.pending = result.catch(() => {});
return result;
},
load: function() {
return this.queue(() => this.recoverPending().then(() => {
const config = reloadConfig().then(() => ({
groups: uci.sections(CONFIG, 'group'), devices: uci.sections(CONFIG, 'device')
}));
const sources = Promise.all([
readSource('hints', () => callHints().then(requireObject)),
readSource('leases', () => callLeases().then(requireObject)),
readSource('neighbors', () => callNeighbors().then(reply => {
if (requireObject(reply).ok !== true || !Array.isArray(reply.neighbors)) throw new Error(reply.error || _('Invalid discovery response'));
return reply;
})),
readSource('wifi', wifiStations),
readSource('arp', () => fs.read('/proc/net/arp').then(model.parseArp))
]);
return Promise.all([ config, sources ]).then(results => {
const data = Object.assign({ errors: [], discoveryComplete: true }, results[0]);
for (const result of results[1]) {
if (!result.ok) {
data.errors.push(result.name + ': ' + result.error);
data.discoveryComplete = false;
continue;
}
if (result.name === 'wifi') {
data.wifi = result.value.stations;
data.discoveryComplete = data.discoveryComplete && result.value.complete;
data.errors.push(...result.value.errors);
} else data[result.name] = result.value;
}
return data;
});
}));
},
recoverPending: function() {
if (!this.rollbackPending) return Promise.resolve();
return callRevert(CONFIG).then(() => { this.rollbackPending = false; });
},
mutate: function(change) {
return this.queue(() => {
if (L.hasViewPermission() !== true) throw new Error(_('Read-only access'));
let staged = false;
return this.recoverPending().then(reloadConfig).then(change).then(() => {
staged = true;
return uci.save();
}).then(() => callCommit(CONFIG)).catch(error => {
if (!staged) throw error;
this.rollbackPending = true;
return this.recoverPending().then(() => { throw error; }, rollbackError => {
throw new Error(_('%s; failed to discard pending changes: %s').format(error.message, rollbackError.message));
});
}).finally(() => uci.unload(CONFIG));
});
},
saveDevice: function(mac, name, remark, group, type) {
return this.mutate(() => {
const normalized = model.normalizeMac(mac);
if (!normalized) throw new Error(_('Enter a valid MAC address'));
const deviceName = validateText(name, 64), deviceRemark = validateText(remark, 256);
if (group && group !== 'ungrouped') requireGroup(group);
const matches = uci.sections(CONFIG, 'device').filter(section => model.sectionMac(section) === normalized);
const sid = matches.length ? matches[matches.length - 1]['.name'] : model.getSectionId(normalized);
if (!matches.length) {
if (uci.get(CONFIG, sid)) throw new Error(_('A conflicting configuration record exists'));
uci.add(CONFIG, 'device', sid);
}
for (const section of matches) if (section['.name'] !== sid) uci.remove(CONFIG, section['.name']);
uci.set(CONFIG, sid, 'mac', normalized);
for (const [ key, value ] of Object.entries({ name: deviceName, remark: deviceRemark,
group: group && group !== 'ungrouped' ? group : '',
type: type && type !== 'auto' ? type : '' })) {
if (value) uci.set(CONFIG, sid, key, value);
else uci.unset(CONFIG, sid, key);
}
});
},
deleteDevice: function(device) {
return this.mutate(() => {
for (const section of uci.sections(CONFIG, 'device')) {
if (model.sectionMac(section) === device.mac) uci.remove(CONFIG, section['.name']);
}
});
},
addGroup: function(name) {
return this.mutate(() => {
const text = validateGroupName(name);
let id;
do { id = 'grp_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); } while (uci.get(CONFIG, id));
uci.add(CONFIG, 'group', id);
uci.set(CONFIG, id, 'name', text);
});
},
renameGroup: function(id, name) {
return this.mutate(() => {
requireGroup(id);
uci.set(CONFIG, id, 'name', validateGroupName(name, id));
});
},
deleteGroup: function(id) {
return this.mutate(() => {
requireGroup(id);
uci.remove(CONFIG, id);
for (const section of uci.sections(CONFIG, 'device')) {
if (section.group === id) uci.unset(CONFIG, section['.name'], 'group');
}
});
}
});