565 lines
20 KiB
JavaScript
565 lines
20 KiB
JavaScript
/* FIPS management page — sovereign://fips
|
|
*
|
|
* Tabs: Status | Peers | Network | Tree
|
|
*
|
|
* - Status: FIPS + Tor status cards (sovereign://fips/status)
|
|
* - Peers: direct peers table + add-peer form (sovereign://fips/peers)
|
|
* - Network: mesh summary + known-nodes table (sovereign://fips/network)
|
|
* - Tree: spanning tree view (sovereign://fips/tree)
|
|
*
|
|
* Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme
|
|
* handler does not reliably support fetch() for sovereign:// URLs.
|
|
*/
|
|
|
|
function sovereignGet(url) {
|
|
return new Promise(function(resolve, reject) {
|
|
var x = new XMLHttpRequest();
|
|
x.open('GET', url, true);
|
|
x.onreadystatechange = function() {
|
|
if (x.readyState !== 4) return;
|
|
try { resolve(JSON.parse(x.responseText)); }
|
|
catch (e) { reject(e); }
|
|
};
|
|
x.onerror = function() { reject(new Error('XHR error')); };
|
|
x.send();
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s == null ? '' : String(s);
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function showMsg(text, isErr) {
|
|
var el = document.getElementById('status-msg');
|
|
el.textContent = text || '';
|
|
el.className = 'status show ' + (isErr ? 'err' : 'ok');
|
|
if (!text) el.className = 'status';
|
|
}
|
|
|
|
function stateDotClass(state) {
|
|
return 'state-dot ' + (state || '');
|
|
}
|
|
|
|
/* Global: our own FIPS node npub (from show_status), used to pin our
|
|
* node in the Directory tab. */
|
|
var gOurNpub = '';
|
|
|
|
/* ── Tab switching ──────────────────────────────────────────────── */
|
|
|
|
function switchTab(tabName) {
|
|
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
});
|
|
document.querySelectorAll('.tab-panel').forEach(function(panel) {
|
|
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
|
|
});
|
|
/* Widen the page for the directory tab (long npub list needs width). */
|
|
document.body.classList.toggle('wide', tabName === 'directory');
|
|
/* Load tab data on demand. */
|
|
if (tabName === 'network') fetchNetwork();
|
|
if (tabName === 'tree') fetchTree();
|
|
if (tabName === 'directory') fetchDirectory();
|
|
}
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
|
|
});
|
|
|
|
/* ── Status tab ─────────────────────────────────────────────────── */
|
|
|
|
function renderFipsStatus(data) {
|
|
var f = data.fips || {};
|
|
var state = f.state || 'disabled';
|
|
/* Store our npub for the Directory tab pinning. */
|
|
gOurNpub = f.node_npub || '';
|
|
document.getElementById('fips-state').innerHTML =
|
|
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
|
|
document.getElementById('fips-node').textContent = f.node_npub || '—';
|
|
var tun = '—';
|
|
if (f.tun_name && f.tun_name.length) {
|
|
tun = f.tun_name + (f.tun_active ? ' (active)' : ' (inactive)');
|
|
}
|
|
document.getElementById('fips-tun').textContent = tun;
|
|
document.getElementById('fips-peers').textContent =
|
|
(f.peer_count != null ? f.peer_count : '—') + ' connected';
|
|
document.getElementById('fips-tree').textContent = f.tree_state || '—';
|
|
document.getElementById('fips-ownership').textContent =
|
|
f.ownership || '—';
|
|
document.getElementById('fips-control').textContent =
|
|
f.control_socket || '—';
|
|
|
|
var errRow = document.getElementById('fips-error-row');
|
|
var errEl = document.getElementById('fips-error');
|
|
if (f.error && f.error.length) {
|
|
errEl.textContent = f.error;
|
|
errRow.style.display = '';
|
|
} else {
|
|
errRow.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function renderTorStatus(data) {
|
|
var t = data.tor || {};
|
|
var state = t.state || 'disabled';
|
|
document.getElementById('tor-state').innerHTML =
|
|
esc(state) + '<span class="' + stateDotClass(state) + '"></span>';
|
|
document.getElementById('tor-bootstrap').textContent =
|
|
(t.bootstrap_progress != null ? t.bootstrap_progress : '—') + '%';
|
|
document.getElementById('tor-socks').textContent =
|
|
t.socks_endpoint || '—';
|
|
document.getElementById('tor-ownership').textContent =
|
|
t.ownership || '—';
|
|
|
|
var errRow = document.getElementById('tor-error-row');
|
|
var errEl = document.getElementById('tor-error');
|
|
if (t.error && t.error.length) {
|
|
errEl.textContent = t.error;
|
|
errRow.style.display = '';
|
|
} else {
|
|
errRow.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function fetchStatus() {
|
|
return sovereignGet('sovereign://fips/status?_=' + Date.now())
|
|
.then(function(data) {
|
|
renderFipsStatus(data);
|
|
renderTorStatus(data);
|
|
})
|
|
.catch(function(e) {
|
|
showMsg('Failed to load status: ' + e.message, true);
|
|
});
|
|
}
|
|
|
|
/* ── Peers tab ──────────────────────────────────────────────────── */
|
|
|
|
function renderPeers(data) {
|
|
var body = document.getElementById('peers-body');
|
|
var table = document.getElementById('peers-table');
|
|
var empty = document.getElementById('peers-empty');
|
|
|
|
var peers = data.peers || [];
|
|
if (!peers.length) {
|
|
table.style.display = 'none';
|
|
empty.style.display = '';
|
|
empty.textContent = data.error
|
|
? 'No peers (' + data.error + ')'
|
|
: 'No peers connected.';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
table.style.display = '';
|
|
var html = '';
|
|
peers.forEach(function(p) {
|
|
var npub = p.npub || p.id || '';
|
|
var addr = p.transport_addr || p.address || p.addr || '';
|
|
var transport = p.transport_type || p.transport || '';
|
|
var state = p.connectivity || p.state || p.status || '';
|
|
var label = p.display_name || '';
|
|
html += '<tr>';
|
|
html += '<td class="mono">' + esc(npub) +
|
|
(label ? '<br><span class="note">' + esc(label) + '</span>' : '') +
|
|
'</td>';
|
|
html += '<td class="mono">' + esc(addr) + '</td>';
|
|
html += '<td>' + esc(transport) + '</td>';
|
|
html += '<td class="peer-state-' + esc(state) + '">' + esc(state) + '</td>';
|
|
html += '<td><button class="btn btn-del" data-npub="' + esc(npub) +
|
|
'" onclick="disconnectPeer(\'' + esc(npub).replace(/'/g, "\\'") +
|
|
'\')">✕</button></td>';
|
|
html += '</tr>';
|
|
});
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
function fetchPeers() {
|
|
return sovereignGet('sovereign://fips/peers?_=' + Date.now())
|
|
.then(function(data) { renderPeers(data); })
|
|
.catch(function(e) {
|
|
document.getElementById('peers-empty').textContent =
|
|
'Failed to load peers: ' + e.message;
|
|
});
|
|
}
|
|
|
|
function connectPeer() {
|
|
var npub = document.getElementById('peer-npub').value.trim();
|
|
var address = document.getElementById('peer-address').value.trim();
|
|
var transport = document.getElementById('peer-transport').value;
|
|
if (!npub || !address) {
|
|
showMsg('npub and address are required', true);
|
|
return;
|
|
}
|
|
var url = 'sovereign://fips/connect?npub=' + encodeURIComponent(npub) +
|
|
'&address=' + encodeURIComponent(address) +
|
|
'&transport=' + encodeURIComponent(transport);
|
|
sovereignGet(url)
|
|
.then(function(d) {
|
|
if (d.error) {
|
|
showMsg('Connect failed: ' + d.error, true);
|
|
} else {
|
|
showMsg('Peer added');
|
|
document.getElementById('peer-npub').value = '';
|
|
document.getElementById('peer-address').value = '';
|
|
fetchPeers();
|
|
}
|
|
})
|
|
.catch(function(e) { showMsg('Connect failed: ' + e.message, true); });
|
|
}
|
|
|
|
function disconnectPeer(npub) {
|
|
if (!npub) return;
|
|
if (!confirm('Disconnect peer ' + npub + '?')) return;
|
|
var url = 'sovereign://fips/disconnect?npub=' + encodeURIComponent(npub);
|
|
sovereignGet(url)
|
|
.then(function(d) {
|
|
if (d.error) {
|
|
showMsg('Disconnect failed: ' + d.error, true);
|
|
} else {
|
|
showMsg('Peer disconnected');
|
|
fetchPeers();
|
|
}
|
|
})
|
|
.catch(function(e) { showMsg('Disconnect failed: ' + e.message, true); });
|
|
}
|
|
|
|
function restartService() {
|
|
if (!confirm('Restart the FIPS service? This will briefly drop all peer connections.')) return;
|
|
showMsg('Restarting FIPS service…');
|
|
sovereignGet('sovereign://fips/restart')
|
|
.then(function(d) {
|
|
if (d.error) showMsg('Restart failed: ' + d.error, true);
|
|
else showMsg('Restart initiated');
|
|
})
|
|
.catch(function(e) { showMsg('Restart failed: ' + e.message, true); });
|
|
}
|
|
|
|
/* ── Network tab ────────────────────────────────────────────────── */
|
|
|
|
function formatAge(ageMs) {
|
|
if (ageMs == null) return '—';
|
|
var s = Math.floor(ageMs / 1000);
|
|
if (s < 60) return s + 's ago';
|
|
var m = Math.floor(s / 60);
|
|
if (m < 60) return m + 'm ago';
|
|
var h = Math.floor(m / 60);
|
|
if (h < 24) return h + 'h ago';
|
|
return Math.floor(h / 24) + 'd ago';
|
|
}
|
|
|
|
function ageClass(ageMs) {
|
|
if (ageMs == null) return '';
|
|
if (ageMs < 10000) return 'last-seen-fresh';
|
|
if (ageMs < 60000) return 'last-seen-stale';
|
|
return 'last-seen-old';
|
|
}
|
|
|
|
function renderNetwork(data) {
|
|
var s = data.summary || {};
|
|
var nodes = data.nodes || [];
|
|
|
|
/* Mesh summary. */
|
|
document.getElementById('mesh-known').textContent =
|
|
(s.known_nodes != null ? s.known_nodes : '—') +
|
|
(s.max_cache_entries != null ? ' / ' + s.max_cache_entries : '');
|
|
document.getElementById('mesh-direct').textContent =
|
|
(data.peer_count != null ? data.peer_count : '—');
|
|
document.getElementById('mesh-depth').textContent =
|
|
(s.tree_depth != null ? s.tree_depth : '—');
|
|
document.getElementById('mesh-root').textContent =
|
|
s.root_npub ? esc(s.root_npub) : '—';
|
|
document.getElementById('mesh-parent').textContent =
|
|
s.parent_display_name ? esc(s.parent_display_name) :
|
|
(s.is_root ? '(this node is root)' : '—');
|
|
|
|
var errRow = document.getElementById('mesh-error-row');
|
|
var errEl = document.getElementById('mesh-error');
|
|
if (data.error) {
|
|
errEl.textContent = data.error;
|
|
errRow.style.display = '';
|
|
} else {
|
|
errRow.style.display = 'none';
|
|
}
|
|
|
|
/* Known nodes table. */
|
|
var body = document.getElementById('nodes-body');
|
|
var table = document.getElementById('nodes-table');
|
|
var empty = document.getElementById('nodes-empty');
|
|
var countLabel = document.getElementById('nodes-count-label');
|
|
|
|
countLabel.textContent = '(' + nodes.length + ' nodes)';
|
|
|
|
if (!nodes.length) {
|
|
table.style.display = 'none';
|
|
empty.style.display = '';
|
|
empty.textContent = data.error
|
|
? 'Network unavailable: ' + data.error
|
|
: 'No known nodes (FIPS may not be ready).';
|
|
return;
|
|
}
|
|
|
|
/* Sort by last_seen descending (most recent first). */
|
|
nodes.sort(function(a, b) {
|
|
var aT = a.last_seen_ms || 0;
|
|
var bT = b.last_seen_ms || 0;
|
|
return bT - aT;
|
|
});
|
|
|
|
empty.style.display = 'none';
|
|
table.style.display = '';
|
|
var html = '';
|
|
nodes.forEach(function(n) {
|
|
var npub = n.npub || '';
|
|
var name = n.display_name || '—';
|
|
var ipv6 = n.ipv6_addr || '';
|
|
var age = n.age_ms != null ? n.age_ms : null;
|
|
var direct = n.is_direct_peer;
|
|
var badge = '<span class="peer-badge' +
|
|
(direct ? '' : ' indirect') + '" title="' +
|
|
(direct ? 'direct peer' : 'discovery only') + '"></span>';
|
|
html += '<tr>';
|
|
html += '<td>' + badge + '</td>';
|
|
html += '<td class="mono">' + esc(npub) + '</td>';
|
|
html += '<td>' + esc(name) + '</td>';
|
|
html += '<td class="mono">' + esc(ipv6) + '</td>';
|
|
html += '<td class="' + ageClass(age) + '">' + formatAge(age) + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
function fetchNetwork() {
|
|
return sovereignGet('sovereign://fips/network?_=' + Date.now())
|
|
.then(function(data) { renderNetwork(data); })
|
|
.catch(function(e) {
|
|
document.getElementById('nodes-empty').textContent =
|
|
'Failed to load network: ' + e.message;
|
|
});
|
|
}
|
|
|
|
/* ── Tree tab ───────────────────────────────────────────────────── */
|
|
|
|
function renderTree(data) {
|
|
var t = data.tree || {};
|
|
var errRow = document.getElementById('tree-error-row');
|
|
var errEl = document.getElementById('tree-error');
|
|
|
|
if (data.error) {
|
|
errEl.textContent = data.error;
|
|
errRow.style.display = '';
|
|
document.getElementById('tree-peers-empty').textContent =
|
|
'Tree unavailable: ' + data.error;
|
|
document.getElementById('tree-peers-table').style.display = 'none';
|
|
/* Clear summary. */
|
|
['tree-root','tree-is-root','tree-depth','tree-parent','tree-node-addr']
|
|
.forEach(function(id) { document.getElementById(id).textContent = '—'; });
|
|
return;
|
|
}
|
|
errRow.style.display = 'none';
|
|
|
|
document.getElementById('tree-root').textContent =
|
|
t.root_npub || t.root || '—';
|
|
document.getElementById('tree-is-root').textContent =
|
|
t.is_root ? 'yes' : 'no';
|
|
document.getElementById('tree-depth').textContent =
|
|
(t.depth != null ? t.depth : '—');
|
|
document.getElementById('tree-parent').textContent =
|
|
t.parent_display_name || t.parent || '—';
|
|
document.getElementById('tree-node-addr').textContent =
|
|
t.my_node_addr || '—';
|
|
|
|
/* Tree peers (1-hop neighbors). */
|
|
var body = document.getElementById('tree-peers-body');
|
|
var table = document.getElementById('tree-peers-table');
|
|
var empty = document.getElementById('tree-peers-empty');
|
|
var peers = t.peers || [];
|
|
|
|
if (!peers.length) {
|
|
table.style.display = 'none';
|
|
empty.style.display = '';
|
|
empty.textContent = 'No tree peers.';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
table.style.display = '';
|
|
var myAddr = t.my_node_addr || '';
|
|
var parentAddr = t.parent || '';
|
|
var html = '';
|
|
peers.forEach(function(p) {
|
|
var addr = p.node_addr || '';
|
|
var name = p.display_name || '—';
|
|
var depth = p.depth != null ? p.depth : '—';
|
|
var dist = p.distance_to_us != null ? p.distance_to_us : '—';
|
|
var role = '';
|
|
if (addr === parentAddr && !t.is_root) {
|
|
role = '<span class="tree-role parent">parent</span>';
|
|
} else if (addr === t.root) {
|
|
role = '<span class="tree-role root">root</span>';
|
|
} else {
|
|
role = '<span class="tree-role child">peer</span>';
|
|
}
|
|
html += '<tr>';
|
|
html += '<td class="mono">' + esc(addr) + '</td>';
|
|
html += '<td>' + esc(name) + '</td>';
|
|
html += '<td>' + esc(depth) + '</td>';
|
|
html += '<td>' + esc(dist) + '</td>';
|
|
html += '<td>' + role + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
function fetchTree() {
|
|
return sovereignGet('sovereign://fips/tree?_=' + Date.now())
|
|
.then(function(data) { renderTree(data); })
|
|
.catch(function(e) {
|
|
document.getElementById('tree-peers-empty').textContent =
|
|
'Failed to load tree: ' + e.message;
|
|
});
|
|
}
|
|
|
|
/* ── Directory tab ───────────────────────────────────────────────── */
|
|
|
|
function renderDirectory(data) {
|
|
var body = document.getElementById('dir-body');
|
|
var table = document.getElementById('dir-table');
|
|
var empty = document.getElementById('dir-empty');
|
|
var countLabel = document.getElementById('dir-count-label');
|
|
var actions = document.getElementById('dir-actions');
|
|
var nodes = data.nodes || [];
|
|
|
|
countLabel.textContent = '(' + nodes.length + ' nodes)';
|
|
|
|
if (!nodes.length) {
|
|
table.style.display = 'none';
|
|
actions.style.display = 'none';
|
|
empty.style.display = '';
|
|
empty.textContent = data.error
|
|
? 'Directory unavailable: ' + data.error
|
|
: 'No advertised nodes found on the advert relays.';
|
|
return;
|
|
}
|
|
|
|
/* Sort: our own node first (pinned), then by created_at descending. */
|
|
nodes.sort(function(a, b) {
|
|
var aOurs = (a.npub && a.npub === gOurNpub) ? 1 : 0;
|
|
var bOurs = (b.npub && b.npub === gOurNpub) ? 1 : 0;
|
|
if (aOurs !== bOurs) return bOurs - aOurs;
|
|
var aT = a.created_at || 0;
|
|
var bT = b.created_at || 0;
|
|
return bT - aT;
|
|
});
|
|
|
|
empty.style.display = 'none';
|
|
table.style.display = '';
|
|
actions.style.display = '';
|
|
var html = '';
|
|
nodes.forEach(function(n) {
|
|
var npub = n.npub || '';
|
|
var direct = n.is_direct_peer;
|
|
var isOurs = (npub && npub === gOurNpub);
|
|
var badge = '<span class="peer-badge' +
|
|
(direct ? '' : ' indirect') + '" title="' +
|
|
(direct ? 'direct peer' : 'not connected') + '"></span>';
|
|
|
|
/* Compact endpoints: "udp 1.2.3.4:2121" (first one only, +N if more). */
|
|
var endpointsStr = '—';
|
|
if (n.endpoints && n.endpoints.length) {
|
|
var ep = n.endpoints[0];
|
|
endpointsStr = esc(ep.transport || '?') + ' ' + esc(ep.addr || '');
|
|
if (n.endpoints.length > 1) endpointsStr += ' +' + (n.endpoints.length - 1);
|
|
}
|
|
|
|
/* Connect: clickable text, not a button. */
|
|
var firstEp = (n.endpoints && n.endpoints[0]) || {};
|
|
var addr = firstEp.addr || '';
|
|
var transport = firstEp.transport || 'udp';
|
|
var connectLink = '';
|
|
if (isOurs) {
|
|
connectLink = '<span class="dir-ours-tag">this is you</span>';
|
|
} else if (addr && !direct) {
|
|
connectLink = '<a class="dir-connect-link" href="javascript:void(0)" onclick="connectFromDirectory(\'' +
|
|
esc(npub).replace(/'/g, "\\'") + '\', \'' +
|
|
esc(addr).replace(/'/g, "\\'") + '\', \'' +
|
|
esc(transport) + '\')">connect</a>';
|
|
} else if (direct) {
|
|
connectLink = '<span class="note">connected</span>';
|
|
}
|
|
|
|
var rowClass = isOurs ? ' class="dir-ours-row"' : '';
|
|
html += '<tr' + rowClass + '>';
|
|
html += '<td>' + badge + '</td>';
|
|
html += '<td class="mono">' + esc(npub) + '</td>';
|
|
html += '<td class="mono">' + endpointsStr + '</td>';
|
|
html += '<td>' + connectLink + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
function fetchDirectory() {
|
|
var empty = document.getElementById('dir-empty');
|
|
empty.style.display = '';
|
|
empty.textContent = 'Querying Nostr advert relays (this takes a few seconds)…';
|
|
document.getElementById('dir-table').style.display = 'none';
|
|
document.getElementById('dir-actions').style.display = 'none';
|
|
return sovereignGet('sovereign://fips/directory?_=' + Date.now())
|
|
.then(function(data) { renderDirectory(data); })
|
|
.catch(function(e) {
|
|
empty.textContent = 'Failed to load directory: ' + e.message;
|
|
});
|
|
}
|
|
|
|
function connectFromDirectory(npub, address, transport) {
|
|
/* Switch to the Peers tab, pre-fill the add-peer form, and connect. */
|
|
switchTab('peers');
|
|
document.getElementById('peer-npub').value = npub;
|
|
document.getElementById('peer-address').value = address;
|
|
var sel = document.getElementById('peer-transport');
|
|
for (var i = 0; i < sel.options.length; i++) {
|
|
if (sel.options[i].value === transport) {
|
|
sel.selectedIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
connectPeer();
|
|
}
|
|
|
|
/* ── Refresh + auto-refresh ─────────────────────────────────────── */
|
|
|
|
function refresh() {
|
|
showMsg('');
|
|
var activeTab = document.querySelector('.tab-btn.active');
|
|
var tabName = activeTab ? activeTab.dataset.tab : 'status';
|
|
var promises = [fetchStatus(), fetchPeers()];
|
|
if (tabName === 'network') promises.push(fetchNetwork());
|
|
if (tabName === 'tree') promises.push(fetchTree());
|
|
Promise.all(promises).then(function() {});
|
|
}
|
|
|
|
document.getElementById('refresh-btn').addEventListener('click', refresh);
|
|
document.getElementById('connect-btn').addEventListener('click', connectPeer);
|
|
document.getElementById('restart-btn').addEventListener('click', restartService);
|
|
var dirRefreshBtn = document.getElementById('dir-refresh-btn');
|
|
if (dirRefreshBtn) dirRefreshBtn.addEventListener('click', fetchDirectory);
|
|
|
|
/* Initial load. */
|
|
refresh();
|
|
|
|
/* Auto-refresh status + peers every 5s. Network/tree refresh on
|
|
* tab activation and every 10s when active. */
|
|
setInterval(function() {
|
|
fetchStatus();
|
|
fetchPeers();
|
|
}, 5000);
|
|
|
|
setInterval(function() {
|
|
var activeTab = document.querySelector('.tab-btn.active');
|
|
if (!activeTab) return;
|
|
var tabName = activeTab.dataset.tab;
|
|
if (tabName === 'network') fetchNetwork();
|
|
if (tabName === 'tree') fetchTree();
|
|
}, 10000);
|