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:
Raven Scott
2026-05-28 11:35:01 -04:00
parent 1d309aa82d
commit 8854365add
18 changed files with 1243 additions and 665 deletions
@@ -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>
+18 -11
View File
@@ -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;
+124 -50
View File
@@ -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;