forked from snxraven/p2ns
Add core.status RPC, RPC invite diagnostics, and WebSocket stats refresh
- core.status handler and coreStatusRequest; diagnoseInviteIssuesAsync clears stale failed-invite flags when peers report canProvideInvite - Admin invite diagnostics and Core stats UI: per-peer remote state, text status marks, recommendations aligned with invite.deliver RPC - Extract stats-collector; push stats/health/status via subscribe-stats WebSocket; HTTP only on first Stats tab load - Document core.status in PLUGIN_SDK; proxy registers onCoreStatus
This commit is contained in:
@@ -1034,28 +1034,44 @@ function displayInviteDiagnostics(diagnostics) {
|
||||
|
||||
const peerEntries = diagnostics.peers ? Object.entries(diagnostics.peers) : [];
|
||||
if (peerEntries.length > 0) {
|
||||
const formatRemote = (remote) => {
|
||||
if (!remote) return '<span class="text-gray-500">—</span>';
|
||||
if (!remote.ok) return `<span class="text-gray-500">${escapeHtml(remote.error || 'no response')}</span>`;
|
||||
const parts = [remote.nodeType, remote.dnsPassInitialized ? 'dnsPass' : 'no dnsPass'];
|
||||
if (remote.canProvideInvite) parts.push('can invite');
|
||||
return `<span class="text-indigo-300">${escapeHtml(parts.join(' · '))}</span>`;
|
||||
};
|
||||
|
||||
const rows = peerEntries.map(([peerId, p]) => {
|
||||
const rpcOk = p.rpc?.ready;
|
||||
const reqOk = p.requestChannel?.opened;
|
||||
const reqPartial = !!(p.requestChannel?.exists && !p.requestChannel?.opened);
|
||||
const connOk = p.connectionOk;
|
||||
const ack = p.pendingAck;
|
||||
let flags = '';
|
||||
if (p.failedInvite) flags += '<span class="text-red-400" title="invite unavailable">✗inv</span> ';
|
||||
if (ack?.waiting) flags += `<span class="text-yellow-400" title="pending ack">⏳ack${ack.retryCount ? '+' + ack.retryCount : ''}</span> `;
|
||||
if (p.failedInvite) flags += '<span class="text-red-400" title="invite unavailable">unavailable</span> ';
|
||||
if (ack?.waiting) flags += `<span class="text-yellow-400" title="pending ack">ack pending</span> `;
|
||||
const reqMark = reqOk ? '<span class="text-green-400">✓</span>' : (reqPartial ? '<span class="text-yellow-400">○</span>' : '<span class="text-red-400">✗</span>');
|
||||
const rpcMark = rpcOk ? '<span class="text-green-400">✓</span>' : (p.rpc?.attached ? '<span class="text-yellow-400">○</span>' : '<span class="text-red-400">✗</span>');
|
||||
return `
|
||||
<tr class="border-t border-gray-600">
|
||||
<td class="py-1 pr-2 font-mono text-gray-300" title="${escapeHtml(peerId)}">${escapeHtml(shortPeerId(peerId))}</td>
|
||||
<td class="py-1 text-center">${connOk ? '<span class="text-green-400">✓</span>' : '<span class="text-red-400">✗</span>'}</td>
|
||||
<td class="py-1 text-center">${reqOk ? '<span class="text-green-400">✓</span>' : '<span class="text-yellow-400">○</span>'}</td>
|
||||
<td class="py-1 text-center">${rpcOk ? '<span class="text-green-400">✓</span>' : '<span class="text-yellow-400">○</span>'}</td>
|
||||
<td class="py-1 text-center">${reqMark}</td>
|
||||
<td class="py-1 text-center">${rpcMark}</td>
|
||||
<td class="py-1 text-xs">${formatRemote(p.remote)}</td>
|
||||
<td class="py-1 text-xs text-gray-400">${flags || '—'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const remoteHdr = diagnostics.remoteQueried != null
|
||||
? ` <span class="text-gray-500">(${diagnostics.remoteQueried} queried via core.status)</span>`
|
||||
: '';
|
||||
|
||||
contentDiv.innerHTML += `
|
||||
<div class="bg-gray-700 rounded p-2 overflow-x-auto">
|
||||
<div class="text-xs text-gray-400 mb-1">Per-peer</div>
|
||||
<div class="text-xs text-gray-400 mb-1">Per-peer${remoteHdr}</div>
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500">
|
||||
@@ -1063,6 +1079,7 @@ function displayInviteDiagnostics(diagnostics) {
|
||||
<th class="text-center pb-1" title="Hyperswarm connection">Conn</th>
|
||||
<th class="text-center pb-1" title="p2ns.core-request">Req</th>
|
||||
<th class="text-center pb-1" title="p2ns.core-request-rpc">RPC</th>
|
||||
<th class="text-left pb-1">Remote</th>
|
||||
<th class="text-left pb-1">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -19,11 +19,21 @@ async function fetchHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
// Render health dashboard
|
||||
// Render health dashboard (HTTP only when no WS data yet)
|
||||
async function renderHealth() {
|
||||
if (healthData) {
|
||||
applyHealthPayload(healthData);
|
||||
return;
|
||||
}
|
||||
const data = await fetchHealth();
|
||||
if (!data) return;
|
||||
|
||||
applyHealthPayload(data);
|
||||
}
|
||||
|
||||
// Apply health payload (HTTP initial or WebSocket)
|
||||
function applyHealthPayload(data) {
|
||||
if (!data) return;
|
||||
healthData = data;
|
||||
updateHealthStatus(data);
|
||||
renderServiceCards(data);
|
||||
updateHealthHistory(data);
|
||||
@@ -97,17 +107,13 @@ function renderHealthHistoryChart() {
|
||||
// Health history chart removed from stats page
|
||||
}
|
||||
|
||||
// Start health updates
|
||||
// Start health updates (data via stats-snapshot + update-health WebSocket)
|
||||
function startHealthUpdates() {
|
||||
if (healthUpdateInterval) return;
|
||||
|
||||
// Initial render
|
||||
if (healthData) {
|
||||
applyHealthPayload(healthData);
|
||||
return;
|
||||
}
|
||||
renderHealth();
|
||||
|
||||
// Update every 5 seconds
|
||||
healthUpdateInterval = setInterval(() => {
|
||||
renderHealth();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Stop health updates
|
||||
@@ -119,6 +125,7 @@ function stopHealthUpdates() {
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.applyHealthPayload = applyHealthPayload;
|
||||
window.renderHealth = renderHealth;
|
||||
window.startHealthUpdates = startHealthUpdates;
|
||||
window.stopHealthUpdates = stopHealthUpdates;
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
// Stats UI functions
|
||||
|
||||
// Fetch stats from API
|
||||
async function fetchStats() {
|
||||
function getStatsMinutes() {
|
||||
return parseInt(document.getElementById('time-range-selector')?.value || '1440', 10);
|
||||
}
|
||||
|
||||
function applyStatsSnapshot(payload) {
|
||||
if (!payload) return;
|
||||
if (payload.stats && payload.historical) {
|
||||
window.statsData = payload.stats;
|
||||
window.historicalData = payload.historical;
|
||||
updateStatsDisplay(payload.stats, payload.historical);
|
||||
initializeCharts(payload.stats, payload.historical);
|
||||
}
|
||||
if (payload.health && window.applyHealthPayload) {
|
||||
window.applyHealthPayload(payload.health);
|
||||
}
|
||||
if (payload.status && window.applyStatusPayload) {
|
||||
window.applyStatusPayload(payload.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial page load only (HTTP)
|
||||
async function fetchStatsInitial() {
|
||||
try {
|
||||
const [statsRes, historicalRes] = await Promise.all([
|
||||
const minutes = getStatsMinutes();
|
||||
const [statsRes, historicalRes, healthRes] = await Promise.all([
|
||||
fetch('/api/stats'),
|
||||
fetch(`/api/stats/historical?minutes=${document.getElementById('time-range-selector')?.value || 1440}`)
|
||||
fetch(`/api/stats/historical?minutes=${minutes}`),
|
||||
fetch('/api/health')
|
||||
]);
|
||||
|
||||
|
||||
if (!statsRes.ok || !historicalRes.ok) {
|
||||
throw new Error('Failed to fetch stats');
|
||||
}
|
||||
|
||||
window.statsData = await statsRes.json();
|
||||
window.historicalData = await historicalRes.json();
|
||||
return { stats: window.statsData, historical: window.historicalData };
|
||||
|
||||
const stats = await statsRes.json();
|
||||
const historical = await historicalRes.json();
|
||||
let health = null;
|
||||
if (healthRes.ok) {
|
||||
health = await healthRes.json();
|
||||
}
|
||||
|
||||
let status = null;
|
||||
try {
|
||||
const statusRes = await fetch('/api/status');
|
||||
if (statusRes.ok) status = await statusRes.json();
|
||||
} catch (_) {
|
||||
// status optional on initial load
|
||||
}
|
||||
|
||||
return { stats, historical, health, status };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to load statistics', 'error');
|
||||
@@ -22,16 +57,38 @@ async function fetchStats() {
|
||||
}
|
||||
}
|
||||
|
||||
// Render stats - main entry point
|
||||
function getStatsRefreshIntervalMs() {
|
||||
return parseInt(document.getElementById('refresh-interval-selector')?.value || '5000', 10);
|
||||
}
|
||||
|
||||
function subscribeStatsWebSocket() {
|
||||
if (!window.ws || window.ws.readyState !== WebSocket.OPEN) return;
|
||||
window.ws.send(JSON.stringify({
|
||||
type: 'subscribe-stats',
|
||||
minutes: getStatsMinutes(),
|
||||
intervalMs: getStatsRefreshIntervalMs()
|
||||
}));
|
||||
}
|
||||
|
||||
function unsubscribeStatsWebSocket() {
|
||||
if (!window.ws || window.ws.readyState !== WebSocket.OPEN) return;
|
||||
window.ws.send(JSON.stringify({ type: 'unsubscribe-stats' }));
|
||||
}
|
||||
|
||||
function requestStatsSnapshotViaWebSocket() {
|
||||
if (!window.ws || window.ws.readyState !== WebSocket.OPEN) return;
|
||||
window.ws.send(JSON.stringify({
|
||||
type: 'request-stats-snapshot',
|
||||
minutes: getStatsMinutes()
|
||||
}));
|
||||
}
|
||||
|
||||
// Render stats - HTTP bootstrap then WebSocket refresh
|
||||
function renderStats() {
|
||||
fetchStats().then(data => {
|
||||
fetchStatsInitial().then((data) => {
|
||||
if (!data) return;
|
||||
// Store the data globally for access in other functions
|
||||
window.statsData = data.stats;
|
||||
window.historicalData = data.historical;
|
||||
// Update all displays and charts
|
||||
updateStatsDisplay(data.stats, data.historical);
|
||||
initializeCharts(data.stats, data.historical);
|
||||
applyStatsSnapshot(data);
|
||||
subscribeStatsWebSocket();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +178,26 @@ function coreStatusColor(ok, warn) {
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
function coreCellMark(ok, partial) {
|
||||
if (ok) return '<span class="text-green-500 font-bold" title="ok">✓</span>';
|
||||
if (partial) return '<span class="text-yellow-500 font-bold" title="partial">○</span>';
|
||||
return '<span class="text-red-500 font-bold" title="no">✗</span>';
|
||||
}
|
||||
|
||||
function formatCoreRemoteState(remote) {
|
||||
if (!remote) return '<span class="text-gray-500">—</span>';
|
||||
if (!remote.ok) {
|
||||
const err = remote.error === 'rpc_not_ready' ? 'RPC not ready' : (remote.error || 'no response');
|
||||
return `<span class="text-gray-500">${err}</span>`;
|
||||
}
|
||||
const parts = [remote.nodeType || '?'];
|
||||
parts.push(remote.dnsPassInitialized ? 'dnsPass' : 'no dnsPass');
|
||||
if (remote.canProvideInvite) parts.push('can invite');
|
||||
else if (remote.nodeType === 'joiner') parts.push('no invite');
|
||||
if (remote.isProcessingInvite) parts.push('pairing…');
|
||||
return `<span class="text-indigo-300">${esc(parts.join(' · '))}</span>`;
|
||||
}
|
||||
|
||||
// Render Core RPC / invite diagnostics (from /api/stats core payload)
|
||||
function renderCoreStats(core) {
|
||||
const esc = window.escapeHtml || ((s) => String(s));
|
||||
@@ -224,22 +301,29 @@ function renderCoreStats(core) {
|
||||
} else {
|
||||
const rows = peerEntries.map(([peerId, p]) => {
|
||||
const flags = [];
|
||||
if (p.failedInvite) flags.push('<span class="text-red-400 text-xs">unavailable</span>');
|
||||
if (p.failedInvite) flags.push('<span class="text-red-400 text-xs">unavailable (local)</span>');
|
||||
if (p.pendingAck?.waiting) {
|
||||
flags.push(`<span class="text-yellow-400 text-xs">ack pending${p.pendingAck.retryCount ? ` (+${p.pendingAck.retryCount})` : ''}</span>`);
|
||||
}
|
||||
const reqPartial = !!(p.requestChannel?.exists && !p.requestChannel?.opened);
|
||||
return `
|
||||
<tr class="border-t border-gray-600/50">
|
||||
<td class="py-2 pr-2 font-mono text-sm" title="${esc(peerId)}">${esc(coreShortPeerId(peerId))}</td>
|
||||
<td class="py-2 text-center">${p.connectionOk ? '<i class="fas fa-circle-check text-green-500"></i>' : '<i class="fas fa-circle-xmark text-red-500"></i>'}</td>
|
||||
<td class="py-2 text-center">${p.requestChannel?.opened ? '<i class="fas fa-circle-check text-green-500"></i>' : '<i class="fas fa-circle text-yellow-500"></i>'}</td>
|
||||
<td class="py-2 text-center">${p.rpc?.ready ? '<i class="fas fa-circle-check text-green-500"></i>' : '<i class="fas fa-circle text-yellow-500"></i>'}</td>
|
||||
<td class="py-2 text-center">${coreCellMark(p.connectionOk, false)}</td>
|
||||
<td class="py-2 text-center">${coreCellMark(p.requestChannel?.opened, reqPartial)}</td>
|
||||
<td class="py-2 text-center">${coreCellMark(p.rpc?.ready, p.rpc?.attached && !p.rpc?.opened)}</td>
|
||||
<td class="py-2 text-xs">${formatCoreRemoteState(p.remote)}</td>
|
||||
<td class="py-2 text-xs theme-text-secondary">${flags.join(' ') || '—'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const remoteNote = core.remoteQueried != null
|
||||
? `<p class="text-xs theme-text-tertiary mb-2">Remote state via core.status RPC (${core.remoteQueried} peer(s) queried)</p>`
|
||||
: '';
|
||||
|
||||
html += `
|
||||
${remoteNote}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
@@ -248,7 +332,8 @@ function renderCoreStats(core) {
|
||||
<th class="text-center pb-2" title="Hyperswarm">Conn</th>
|
||||
<th class="text-center pb-2" title="p2ns.core-request">Req</th>
|
||||
<th class="text-center pb-2" title="p2ns.core-request-rpc">RPC</th>
|
||||
<th class="text-left pb-2">State</th>
|
||||
<th class="text-left pb-2">Remote (core.status)</th>
|
||||
<th class="text-left pb-2">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
@@ -1350,41 +1435,26 @@ function initializeCharts(stats, historical) {
|
||||
}
|
||||
}
|
||||
|
||||
// Start stats updates
|
||||
// Start stats updates (WebSocket push; HTTP only if no data yet)
|
||||
function startStatsUpdates() {
|
||||
const autoRefresh = document.getElementById('auto-refresh-stats');
|
||||
if (!autoRefresh || !autoRefresh.checked) return;
|
||||
|
||||
const intervalSelector = document.getElementById('refresh-interval-selector');
|
||||
const intervalMs = parseInt(intervalSelector?.value || '5000', 10);
|
||||
|
||||
|
||||
stopStatsUpdates();
|
||||
|
||||
fetchStats().then(data => {
|
||||
if (data) {
|
||||
window.statsData = data.stats;
|
||||
window.historicalData = data.historical;
|
||||
updateStatsDisplay(data.stats, data.historical);
|
||||
initializeCharts(data.stats, data.historical);
|
||||
}
|
||||
});
|
||||
|
||||
window.statsUpdateInterval = setInterval(() => {
|
||||
if (window.activeTab === 'stats') {
|
||||
fetchStats().then(data => {
|
||||
if (data) {
|
||||
window.statsData = data.stats;
|
||||
window.historicalData = data.historical;
|
||||
updateStatsDisplay(data.stats, data.historical);
|
||||
initializeCharts(data.stats, data.historical);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
if (!window.statsData) {
|
||||
fetchStatsInitial().then((data) => {
|
||||
if (data) applyStatsSnapshot(data);
|
||||
subscribeStatsWebSocket();
|
||||
});
|
||||
} else {
|
||||
subscribeStatsWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stats updates
|
||||
function stopStatsUpdates() {
|
||||
unsubscribeStatsWebSocket();
|
||||
if (window.statsUpdateInterval) {
|
||||
clearInterval(window.statsUpdateInterval);
|
||||
window.statsUpdateInterval = null;
|
||||
@@ -1414,7 +1484,11 @@ function exportStats() {
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.fetchStats = fetchStats;
|
||||
window.fetchStatsInitial = fetchStatsInitial;
|
||||
window.applyStatsSnapshot = applyStatsSnapshot;
|
||||
window.subscribeStatsWebSocket = subscribeStatsWebSocket;
|
||||
window.unsubscribeStatsWebSocket = unsubscribeStatsWebSocket;
|
||||
window.requestStatsSnapshotViaWebSocket = requestStatsSnapshotViaWebSocket;
|
||||
window.renderStats = renderStats;
|
||||
window.updateStatsDisplay = updateStatsDisplay;
|
||||
window.renderHolesailChildren = renderHolesailChildren;
|
||||
|
||||
Reference in New Issue
Block a user