🎨 Sync 2026-10-09 01:04:17

This commit is contained in:
github-actions[bot] committed 2026-10-09 01:04:17 +08:00
1 parent eb76e89012
commit 812da7f08b
154 files changed
+10613 -3987

No files matched your search

@@ -30,6 +30,7 @@ var rpcNetSet = rpc.declare({ object: 'luci-zerotier', method: 'remote_network_s
var rpcNetDel = rpc.declare({ object: 'luci-zerotier', method: 'remote_network_del', params: [ 'section', 'nwid' ] });
var rpcMemberSet = rpc.declare({ object: 'luci-zerotier', method: 'remote_member_set', params: [ 'section', 'nwid', 'member_id', 'body' ] });
var rpcMemberList = rpc.declare({ object: 'luci-zerotier', method: 'remote_member_list', params: [ 'section', 'nwid' ] });
var rpcNetworkList = rpc.declare({ object: 'luci-zerotier', method: 'remote_network_list', params: [ 'section' ] });
var rpcPeerList = rpc.declare({ object: 'luci-zerotier', method: 'remote_peer_list', params: [ 'section' ] });
var rpcMoonPlan = rpc.declare({ object: 'luci-zerotier', method: 'remote_moon_plan', params: [ 'section' ] });
var rpcMoonApply = rpc.declare({ object: 'luci-zerotier', method: 'remote_moon_apply', params: [ 'section', 'confirm' ] });
@@ -501,7 +502,15 @@ function networksPanel(section) {
* against its entry here and the filter re-renders from it, so no
* keystroke ever costs an SSH round-trip. */
var membersArr = mres.body;
var missingIds = Array.isArray(mres.skipped) ? mres.skipped : [];
if (!membersArr.length) {
/* An empty body with skipped entries is every read failing, not
* an empty network: say so instead of a success message. */
if (missingIds.length) {
into.appendChild(E('div', { 'style': 'color:red;' }, [
_('Could not read any member of this network') + ' (' + missingIds.length + ')' ]));
return;
}
into.appendChild(E('div', { 'style': 'color:orange;' }, [ _('No members have joined this network yet.') ]));
return;
}
@@ -538,6 +547,89 @@ function networksPanel(section) {
return pth || p.paths[0];
}
/* ------------------------------------------------------------ refresh
*
* Every remote call costs an SSH handshake (~2.05s measured on the
* target), and that cost is the handshake, not the member count: one
* member and 31 members both bottom out at ~2.05s, the 30 extra
* fetches adding ~0.6s together. So a refresh re-reads exactly the
* member that changed and leaves every other row -- including one the
* user is typing into -- untouched. Full redraws would also throw away
* the filter text and the scroll position.
*
* peerMap and pok are refreshed alongside because peer state changes
* on its own (a node connects, a path expires) and it costs nothing
* extra to fetch in parallel with the member.
*
* rowRefs maps member id to that row's live cells, so a refresh can
* repaint one row in place instead of rebuilding the tbody.
*/
var rowRefs = {};
function applyPeers(peers) {
pok = Array.isArray(peers);
peerMap = {};
if (pok) peers.forEach(function(p) { if (p && p.address) peerMap[p.address] = p; });
for (var mid in rowRefs) {
var r = rowRefs[mid];
if (!r) continue;
paint(r.status, r.statusText());
paint(r.addr, r.addrText());
}
}
function paint(td, text) {
while (td.firstChild) td.removeChild(td.firstChild);
td.appendChild(typeof text === 'string' ? document.createTextNode(text) : text);
}
function refreshMember(mid) {
return Promise.all([
L.resolveDefault(rpcCtlGet(section, '/controller/network/' + nwid + '/member/' + mid), {}),
L.resolveDefault(rpcPeerList(section), {})
]).then(function (r) {
var cres = r[0], pres2 = r[1];
if (pres2 && (pres2.code === 0 || pres2.code === 200) && Array.isArray(pres2.body))
applyPeers(pres2.body);
if (!cres || cres.code !== 200 || !cres.body) return null;
/* Merge onto the cached model rather than replacing the row, so
* an input the user is focused in keeps focus and value. */
var m = null;
for (var i = 0; i < membersArr.length; i++)
if (membersArr[i].id === mid) { m = membersArr[i]; break; }
if (!m) return null;
m.name = cres.body.name || '';
m.authorized = !!cres.body.authorized;
m.activeBridge = !!cres.body.activeBridge;
m.ipAssignments = cres.body.ipAssignments || [];
m.vMajor = cres.body.vMajor;
m.vMinor = cres.body.vMinor;
m.revision = cres.body.revision;
var r = rowRefs[mid];
if (r) {
r.paintIp();
r.authCb.checked = m.authorized;
r.bridgeCb.checked = m.activeBridge;
}
return m;
});
}
/* How long after authorizing the address actually appears. Measured on a
* real 1.14.2 controller: the POST returns in 1ms with authorized=true
* but ipAssignments stays empty at +1s, +2s and +4s, and the address
* is there at +8s -- the node has to re-fetch the network config and
* come up before the controller assigns it. So the row is marked
* "assigning" immediately and re-read once after that delay; fetching
* right away would always read an empty list and look like a failure. */
var IP_ASSIGN_DELAY = 11000;
var stamp = E('span', { 'style': 'color:#888; font-size:12px;' }, []);
function setStamp() {
var d = new Date();
function p2(n) { return (n < 10 ? '0' : '') + n; }
while (stamp.firstChild) stamp.removeChild(stamp.firstChild);
stamp.appendChild(E('span', {}, [ _('updated') + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds()) ]));
}
var filterI = E('input', { 'type': 'text', 'placeholder': _('Filter by name, address or IP'), 'style': 'width:220px; margin-right:8px;' });
var counterHost = E('span', { 'style': 'color:#888; font-size:12px;' }, []);
var tbody = E('tbody', {});
@@ -550,7 +642,9 @@ function networksPanel(section) {
}
function setCounter(shown) {
while (counterHost.firstChild) counterHost.removeChild(counterHost.firstChild);
counterHost.appendChild(E('span', {}, [ shown + ' / ' + membersArr.length + ' ' + _('members') ]));
var txt = shown + ' / ' + (membersArr.length + missingIds.length) + ' ' + _('members');
if (missingIds.length) txt += ' (' + missingIds.length + ' ' + _('unreadable') + ')';
counterHost.appendChild(E('span', {}, [ txt ]));
}
/* Inline edits post single-field bodies on purpose: the controller
@@ -584,6 +678,7 @@ function networksPanel(section) {
nameBusy = false;
if (r2 && r2.code === 0) { m.name = v; flashBorder(nameI, 'green'); }
else {
nameI.value = m.name || '';
flashBorder(nameI, 'red');
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
}
@@ -598,42 +693,68 @@ function networksPanel(section) {
else if (ev.keyCode === 27) nameI.value = m.name || '';
});
function checkboxTd(field) {
var c = E('input', { 'type': 'checkbox' });
c.checked = !!m[field];
var busy = false;
c.addEventListener('change', function() {
if (busy) return;
busy = true;
var b = {};
b[field] = c.checked;
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify(b)), {}).then(function(r2) {
busy = false;
if (r2 && r2.code === 0) m[field] = c.checked;
else {
c.checked = !!m[field];
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
var cbs = {};
function checkboxTd(field) {
var c = E('input', { 'type': 'checkbox' });
c.checked = !!m[field];
var busy = false;
c.addEventListener('change', function() {
if (busy) return;
busy = true;
var b = {};
b[field] = c.checked;
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify(b)), {}).then(function(r2) {
busy = false;
if (!r2 || r2.code !== 0) {
c.checked = !!m[field];
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
return;
}
m[field] = c.checked;
/* De-authorize re-reads immediately rather than after the
* delay: authorized is already final, and the controller
* keeps ipAssignments across a revoke/restore cycle
* untouched (verified), so only the peer columns can have
* moved. */
if (field === 'authorized') {
if (c.checked) {
m.awaitingIp = true;
drawIp();
setTimeout(function() {
refreshMember(mid).then(function(fm) {
if (fm) fm.awaitingIp = false;
drawIp();
});
}, IP_ASSIGN_DELAY);
} else {
refreshMember(mid).then(function() { drawIp(); });
}
});
}
});
return E('td', { 'style': 'width:9%; text-align:center;' }, [ c ]);
}
});
cbs[field] = c;
return E('td', { 'style': 'width:9%; text-align:center;' }, [ c ]);
}
var ipTd = E('td', { 'style': 'width:13%; font-family:monospace; white-space:nowrap;' }, []);
var ipBusy = false, ipEditing = false;
function drawIp() {
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
var ips = m.ipAssignments || [];
var clickable = E('span', { 'style': 'cursor:pointer;' }, ips.length
? [ ips.join(', ') ]
: [ E('span', { 'style': 'color:orange;' }, [
/* Empty ipAssignments with version -1 means the node has
* never come up on this network; a positive version with
* no IP means the network has no pool. */
(m.vMajor != null && m.vMajor < 0) ? _('never connected') : _('no pool assigned')
]) ]);
ipTd.appendChild(clickable);
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
var ips = m.ipAssignments || [];
if (m.awaitingIp && !ips.length) {
ipTd.appendChild(E('span', { 'style': 'color:#888;' }, [ _('assigning address...') ]));
return;
}
var clickable = E('span', { 'style': 'cursor:pointer;' }, ips.length
? [ ips.join(', ') ]
: [ E('span', { 'style': 'color:orange;' }, [
/* Empty ipAssignments with version -1 means the node has
* never come up on this network; a positive version with
* no IP means the network has no pool. */
(m.vMajor != null && m.vMajor < 0) ? _('never connected') : _('no pool assigned')
]) ]);
ipTd.appendChild(clickable);
}
function startIpEdit() {
if (ipBusy) return;
ipEditing = true;
@@ -696,22 +817,35 @@ function networksPanel(section) {
return pth.address + ((p.latency > 0) ? ' (' + p.latency + ' ms)' : ' (-)');
}
return E('tr', {}, [
E('td', { 'style': 'width:34px; text-align:center;' }, [ rm ]),
E('td', { 'style': 'width:19%;' }, [ nameI ]),
E('td', { 'style': 'width:12%; font-family:monospace; white-space:nowrap;' }, [
E('code', {}, [ E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); } }, [ mid ]) ])
]),
checkboxTd('authorized'),
checkboxTd('activeBridge'),
ipTd,
E('td', { 'style': 'width:12%;' }, [ statusContent() ]),
E('td', { 'style': 'width:26%; font-family:monospace;' }, [ addrContent() ])
]);
var statusTd = E('td', { 'style': 'width:12%;' }, [ statusContent() ]);
var addrTd = E('td', { 'style': 'width:26%; font-family:monospace;' }, [ addrContent() ]);
var tr = E('tr', {}, [
E('td', { 'style': 'width:34px; text-align:center;' }, [ rm ]),
E('td', { 'style': 'width:19%;' }, [ nameI ]),
E('td', { 'style': 'width:12%; font-family:monospace; white-space:nowrap;' }, [
E('code', {}, [ E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); } }, [ mid ]) ])
]),
checkboxTd('authorized'),
checkboxTd('activeBridge'),
ipTd,
statusTd,
addrTd
]);
rowRefs[mid] = {
status: statusTd, addr: addrTd,
statusText: statusContent, addrText: addrContent,
paintIp: drawIp,
authCb: cbs.authorized, bridgeCb: cbs.activeBridge
};
return tr;
}
function drawRows() {
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
/* Clear the cell refs of the rows being replaced: a stale entry would
* make applyPeers repaint a detached node, which fails silently
* and leaves peer status frozen after any filter change. */
rowRefs = {};
var q = filterI.value ? filterI.value.toLowerCase() : '';
var shown = 0;
membersArr.forEach(function(m) {
@@ -729,6 +863,36 @@ function networksPanel(section) {
* the network <table>: nested tables share column widths (see the
* comment above netRow's return). Header labels may wrap; the fixed
* layout plus 34px + 19+12+9+9+13+12+26% keeps the grid stable. */
/* Deliberately a full reload, unlike refreshMember: this button exists for the
* case where the same controller is being edited in another tool. The
* filter text survives because drawRows reads it back out of filterI. */
function refreshAll() {
Promise.all([
L.resolveDefault(rpcMemberList(section, nwid), {}),
L.resolveDefault(rpcPeerList(section), {})
]).then(function(r) {
var mres2 = r[0], pres3 = r[1];
var msk2 = Array.isArray(mres2 && mres2.skipped) ? mres2.skipped : [];
/* An empty body with skipped entries is every read failing, not an
* empty network: keep the table rather than blank it, and say so.
* The stamp claims the table is fresh, so only a fetch that
* actually replaced it may move it. */
var mok2 = !!(mres2 && (mres2.code === 0 || mres2.code === 200) && Array.isArray(mres2.body) && (mres2.body.length || !msk2.length));
if (mok2) {
membersArr = mres2.body;
missingIds = msk2;
}
else
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(mres2, _('Could not refresh the member list; the table is unchanged')) ]), 'warning');
if (pres3 && (pres3.code === 0 || pres3.code === 200) && Array.isArray(pres3.body))
applyPeers(pres3.body);
drawRows();
if (mok2) setStamp();
});
}
var refreshBtn = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Refresh') ]);
refreshBtn.addEventListener('click', refreshAll);
var hdr = E('tr', {}, [
E('th', { 'style': 'width:34px;' }, []),
E('th', { 'style': 'width:19%;' }, [_('Member name')]),
@@ -739,7 +903,8 @@ function networksPanel(section) {
E('th', { 'style': 'width:12%;' }, [_('Peer status')]),
E('th', { 'style': 'width:26%;' }, [_('Peer address / latency')])
]);
into.appendChild(E('div', { 'style': 'display:flex; gap:8px; align-items:center; margin:6px 0;' }, [ filterI, counterHost ]));
into.appendChild(E('div', { 'style': 'display:flex; gap:8px; align-items:center; margin:6px 0;' },
[ filterI, counterHost, refreshBtn, stamp ]));
/* The scroll wrapper and min-width are load-bearing: table-layout:fixed
* pins column widths but does NOT clip or wrap content, so a nowrap cell
* wider than its share prints over the next column. Screenshotting the
@@ -751,20 +916,21 @@ function networksPanel(section) {
E('table', { 'class': 'table', 'style': 'table-layout:fixed; width:100%; min-width:1040px;' }, [ hdr, tbody ])
]));
drawRows();
setStamp();
});
}
function load() {
while (table.firstChild) table.removeChild(table.firstChild);
table.appendChild(E('div', { 'style': 'color:orange;' }, [ _('Loading...') ]));
L.resolveDefault(rpcCtlGet(section, '/controller/network'), {}).then(function(res) {
L.resolveDefault(rpcNetworkList(section), {}).then(function(res) {
while (table.firstChild) table.removeChild(table.firstChild);
if (res && res.code !== 200) {
var nets = res && Array.isArray(res.body) ? res.body : null;
if (!nets) {
table.appendChild(E('div', { 'style': 'color:red;' }, [ errText(res, _('Could not reach the controller')) ]));
return;
}
var ids = Array.isArray(res.body) ? res.body : [];
if (!ids.length) {
if (!nets.length) {
table.appendChild(E('div', { 'style': 'color:orange;' }, [ _('This controller has no networks yet.') ]));
return;
}
@@ -779,11 +945,13 @@ function networksPanel(section) {
* forms stretch the nwid column (see netRow). So the header goes in
* its own table and each network follows as a sibling block. */
table.appendChild(E('table', { 'class': 'table' }, [ head ]));
ids.forEach(function(nwid) {
L.resolveDefault(rpcCtlGet(section, '/controller/network/' + nwid), {}).then(function(d) {
if (d && d.code === 200 && d.body) table.appendChild(netRow(d.body));
});
});
nets.forEach(function(nw) { table.appendChild(netRow(nw)); });
var skipped = res.skipped || [];
if (skipped.length) {
table.appendChild(E('div', { 'style': 'color:red; margin-top:6px;' }, [
_('Could not read these networks, so they are not shown: ') + skipped.join(', ')
]));
}
});
}
@@ -873,9 +1041,7 @@ function moonPanel(section) {
/* --------------------------------------------------------------------- page */
return view.extend({
load: function() {
return Promise.all([ uci.load('zerotier') ]);
},
load: function() {},
render: function() {
var listBox = E('div', {});