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:
@@ -1753,6 +1753,7 @@ Core network invite/consensus uses `p2ns.core-request-rpc` only (no `p2ns.core-i
|
||||
| `consensus.removeDomain` | event | `{ domain }` |
|
||||
| `consensus.recalculate` | event | `{ domain }` (`'all'` supported) |
|
||||
| `consensus.removeConflictClaim` | event | `{ domain }` |
|
||||
| `core.status` | request/response | → `{ nodeType, dnsPassInitialized, canProvideInvite, … }` (admin diagnostics) |
|
||||
|
||||
See [`includes/core/core-rpc-contract.js`](../../includes/core/core-rpc-contract.js) and [`includes/core/core-swarm-handlers.js`](../../includes/core/core-swarm-handlers.js).
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Payload builders for admin stats page (HTTP initial load + WebSocket refresh).
|
||||
*/
|
||||
|
||||
const state = require('../../infrastructure/state');
|
||||
const { metrics } = require('../../maintenance/metrics');
|
||||
const { collectAdminStats, collectHistoricalMinutes } = require('./stats-collector');
|
||||
|
||||
function getAdminStatusPayload() {
|
||||
return {
|
||||
isMaster: state.isMaster,
|
||||
isConnected: !!state.dnsPass,
|
||||
peersCount: state.connectedPeers?.size || 0,
|
||||
pid: process.pid,
|
||||
isShuttingDown: state.isShuttingDown || false
|
||||
};
|
||||
}
|
||||
|
||||
function getAdminHealthPayload() {
|
||||
const dnsHealthy = !!state.dnsPass && state.dnsPass.opened !== false;
|
||||
const proxyHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
|
||||
const swarmHealthy = state.connectedPeers !== undefined;
|
||||
const corestoreHealthy = state.dnsPass && state.dnsPass.base && state.dnsPass.base.writable !== undefined;
|
||||
const allServicesHealthy = dnsHealthy && proxyHealthy && swarmHealthy && corestoreHealthy;
|
||||
const dnsServerHealthy = process.env.DISABLE_DNS_SERVER !== 'true';
|
||||
const httpsServerHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
|
||||
|
||||
return {
|
||||
status: allServicesHealthy ? 'healthy' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Date.now() - (metrics?.startTime || Date.now()),
|
||||
services: {
|
||||
dns: {
|
||||
enabled: dnsServerHealthy,
|
||||
healthy: dnsHealthy,
|
||||
initialized: !!state.dnsPass,
|
||||
details: {
|
||||
passReady: state.dnsPass?.opened !== false,
|
||||
domainsCount: state.domainToIPMap?.size || 0
|
||||
}
|
||||
},
|
||||
proxy: {
|
||||
enabled: httpsServerHealthy,
|
||||
healthy: proxyHealthy,
|
||||
details: {
|
||||
httpsEnabled: process.env.DISABLE_PROXY_SERVER !== 'true',
|
||||
httpEnabled: process.env.DISABLE_PROXY_SERVER !== 'true'
|
||||
}
|
||||
},
|
||||
swarm: {
|
||||
healthy: swarmHealthy,
|
||||
details: {
|
||||
connectedPeers: state.connectedPeers?.size || 0,
|
||||
isMaster: state.isMaster || false
|
||||
}
|
||||
}
|
||||
},
|
||||
dependencies: {
|
||||
corestore: {
|
||||
healthy: corestoreHealthy,
|
||||
details: {
|
||||
initialized: !!state.dnsPass,
|
||||
writable: state.dnsPass?.base?.writable || false
|
||||
}
|
||||
},
|
||||
hyperswarm: {
|
||||
healthy: swarmHealthy,
|
||||
details: {
|
||||
connectedPeers: state.connectedPeers?.size || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function buildStatsPageSnapshot(minutes = 1440) {
|
||||
const [stats, historical] = await Promise.all([
|
||||
collectAdminStats(),
|
||||
Promise.resolve(collectHistoricalMinutes(minutes))
|
||||
]);
|
||||
return {
|
||||
type: 'stats-snapshot',
|
||||
timestamp: Date.now(),
|
||||
minutes,
|
||||
stats,
|
||||
historical,
|
||||
health: getAdminHealthPayload(),
|
||||
status: getAdminStatusPayload()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAdminStatusPayload,
|
||||
getAdminHealthPayload,
|
||||
buildStatsPageSnapshot
|
||||
};
|
||||
@@ -573,7 +573,9 @@ async function handleDiagnosticsRoutes(req, res) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const diagnostics = state.diagnoseInviteIssues();
|
||||
const diagnostics = typeof state.diagnoseInviteIssuesAsync === 'function'
|
||||
? await state.diagnoseInviteIssuesAsync()
|
||||
: state.diagnoseInviteIssues();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(diagnostics));
|
||||
trackRequest(urlPath, true);
|
||||
|
||||
@@ -1,37 +1,7 @@
|
||||
const dgram = require('dgram');
|
||||
const state = require('../../../infrastructure/state');
|
||||
const { getMetrics, getHistoricalData, trackRequestWithTiming, trackRequest } = require('../../../maintenance/metrics');
|
||||
const { getHashForDomain } = require('../../../core/core');
|
||||
const { logDebug, logError } = require('../../../infrastructure/logger');
|
||||
const { trackRequestWithTiming, trackRequest } = require('../../../maintenance/metrics');
|
||||
const { logError } = require('../../../infrastructure/logger');
|
||||
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||
const { parseMinutesToMs } = require('../../../infrastructure/utils');
|
||||
const pidusage = require('pidusage');
|
||||
|
||||
// Import db-manager and replication-manager for HyperDB stats
|
||||
let dbManager = null;
|
||||
let replicationManager = null;
|
||||
let driveReplicationManager = null;
|
||||
let driveManager = null;
|
||||
try {
|
||||
dbManager = require('../../../plugins/db-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'db-manager not available for stats');
|
||||
}
|
||||
try {
|
||||
replicationManager = require('../../../plugins/replication-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'replication-manager not available for stats');
|
||||
}
|
||||
try {
|
||||
driveReplicationManager = require('../../../plugins/drive-replication-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'drive-replication-manager not available for stats');
|
||||
}
|
||||
try {
|
||||
driveManager = require('../../../plugins/drive-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'drive-manager not available for stats');
|
||||
}
|
||||
const { collectAdminStats, collectHistoricalMinutes } = require('../stats-collector');
|
||||
|
||||
async function handleStatsRoutes(req, res) {
|
||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||
@@ -40,282 +10,7 @@ async function handleStatsRoutes(req, res) {
|
||||
if (method === 'GET' && urlPath === '/api/stats') {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const stats = getMetrics();
|
||||
if (state.hypercoreStats && typeof state.hypercoreStats.toJson === 'function') {
|
||||
try {
|
||||
const { normalizeHolepunchStats } = require('../../../infrastructure/holepunch-stats-schema');
|
||||
stats.holepunch = normalizeHolepunchStats(state.hypercoreStats.toJson());
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Failed to collect hypercore stats: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const holesailChildren = [];
|
||||
const pidStatsPromises = [];
|
||||
const pidToChildMap = new Map();
|
||||
|
||||
for (const [id, child] of state.holesailChildren.entries()) {
|
||||
try {
|
||||
const opts = state.holesailOpts.get(id) || {};
|
||||
const info = state.holesailInfos.get(id) || {};
|
||||
const startTime = state.holesailChildStartTimes.get(id);
|
||||
const uptime = startTime ? Date.now() - startTime : 0;
|
||||
const status = child && !child.killed ? 'running' : 'stopped';
|
||||
const pid = child ? child.pid : null;
|
||||
|
||||
if (pid && child && !child.killed) {
|
||||
pidStatsPromises.push(
|
||||
pidusage(pid).then(stats => ({ id, type: 'server', stats })).catch(err => {
|
||||
logDebug('Admin', `Failed to get stats for server child ${id} (PID ${pid}): ${err.message}`);
|
||||
return { id, type: 'server', stats: null };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
pidToChildMap.set(id, {
|
||||
id,
|
||||
type: 'server',
|
||||
status,
|
||||
pid,
|
||||
uptime,
|
||||
cpuUsage: null,
|
||||
memoryUsage: null,
|
||||
opts: {
|
||||
port: opts.port,
|
||||
host: opts.host || '0.0.0.0',
|
||||
protocol: opts.udp ? 'udp' : 'tcp',
|
||||
secure: opts.secure || false,
|
||||
domain: opts.domain || null
|
||||
},
|
||||
info: info
|
||||
});
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting stats for server child ${id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, child] of state.holesailClientChildren.entries()) {
|
||||
try {
|
||||
const opts = state.holesailClientOpts.get(id) || {};
|
||||
const info = state.holesailClientInfos.get(id) || {};
|
||||
const startTime = state.holesailChildStartTimes.get(id);
|
||||
const uptime = startTime ? Date.now() - startTime : 0;
|
||||
const status = child && !child.killed ? 'running' : 'stopped';
|
||||
const pid = child ? child.pid : null;
|
||||
|
||||
if (pid && child && !child.killed) {
|
||||
pidStatsPromises.push(
|
||||
pidusage(pid).then(stats => ({ id, type: 'client', stats })).catch(err => {
|
||||
logDebug('Admin', `Failed to get stats for client child ${id} (PID ${pid}): ${err.message}`);
|
||||
return { id, type: 'client', stats: null };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
pidToChildMap.set(id, {
|
||||
id,
|
||||
type: 'client',
|
||||
status,
|
||||
pid,
|
||||
uptime,
|
||||
cpuUsage: null,
|
||||
memoryUsage: null,
|
||||
opts: {
|
||||
domain: opts.domain || null,
|
||||
port: opts.port,
|
||||
host: opts.host || null,
|
||||
protocol: opts.protocol || 'tcp'
|
||||
},
|
||||
info: info
|
||||
});
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting stats for client child ${id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pidStatsResults = await Promise.all(pidStatsPromises);
|
||||
|
||||
for (const result of pidStatsResults) {
|
||||
if (result.stats) {
|
||||
const childData = pidToChildMap.get(result.id);
|
||||
if (childData) {
|
||||
childData.cpuUsage = {
|
||||
user: result.stats.cpu / 2,
|
||||
system: result.stats.cpu / 2,
|
||||
percentage: result.stats.cpu
|
||||
};
|
||||
childData.memoryUsage = {
|
||||
rss: result.stats.memory,
|
||||
heapUsed: result.stats.memory * 0.8,
|
||||
heapTotal: result.stats.memory,
|
||||
external: 0,
|
||||
arrayBuffers: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const childData of pidToChildMap.values()) {
|
||||
holesailChildren.push(childData);
|
||||
}
|
||||
|
||||
const managedConnections = new Set();
|
||||
for (const child of holesailChildren) {
|
||||
if (child.opts && child.opts.domain && child.opts.port) {
|
||||
managedConnections.add(`${child.opts.domain}:${child.opts.port}`);
|
||||
}
|
||||
}
|
||||
|
||||
const p2pDomainEntries = [];
|
||||
const hashPromises = [];
|
||||
|
||||
for (const [key, holesail] of state.holesails.entries()) {
|
||||
try {
|
||||
if (managedConnections.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyParts = key.split(':');
|
||||
if (keyParts.length !== 2) {
|
||||
logDebug('Admin', `Skipping invalid holesail key format: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const domain = keyParts[0];
|
||||
const port = parseInt(keyParts[1], 10);
|
||||
|
||||
if (isNaN(port)) {
|
||||
logDebug('Admin', `Skipping holesail key with invalid port: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ip = state.domainToIPMap.get ? state.domainToIPMap.get(domain) : state.domainToIPMap[domain];
|
||||
const isPersistent = state.persistentConnections && state.persistentConnections.has(key);
|
||||
const status = holesail && typeof holesail === 'object' ? 'running' : 'stopped';
|
||||
|
||||
let holesailInfo = null;
|
||||
try {
|
||||
if (holesail && typeof holesail === 'object' && holesail.info) {
|
||||
holesailInfo = holesail.info;
|
||||
}
|
||||
} catch (e) {
|
||||
// Info not available
|
||||
}
|
||||
|
||||
p2pDomainEntries.push({
|
||||
key,
|
||||
domain,
|
||||
port,
|
||||
ip,
|
||||
isPersistent,
|
||||
status,
|
||||
holesailInfo
|
||||
});
|
||||
|
||||
hashPromises.push(
|
||||
getHashForDomain(domain).catch(err => {
|
||||
logDebug('Admin', `Could not get hash for domain ${domain}: ${err.message}`);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
logError('Admin', `Error processing p2p domain connection ${key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hashResults = await Promise.all(hashPromises);
|
||||
|
||||
let mainProcessStats = null;
|
||||
try {
|
||||
mainProcessStats = await pidusage(process.pid);
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Failed to get main process stats for p2p connections: ${err.message}`);
|
||||
}
|
||||
|
||||
const p2pConnectionCount = p2pDomainEntries.length || 1;
|
||||
let perConnectionCpu = null;
|
||||
let perConnectionMemory = null;
|
||||
|
||||
if (mainProcessStats) {
|
||||
perConnectionCpu = {
|
||||
user: mainProcessStats.cpu / (p2pConnectionCount * 2),
|
||||
system: mainProcessStats.cpu / (p2pConnectionCount * 2),
|
||||
percentage: mainProcessStats.cpu / p2pConnectionCount
|
||||
};
|
||||
|
||||
perConnectionMemory = {
|
||||
rss: Math.floor(mainProcessStats.memory / p2pConnectionCount),
|
||||
heapUsed: Math.floor((mainProcessStats.memory * 0.8) / p2pConnectionCount),
|
||||
heapTotal: Math.floor(mainProcessStats.memory / p2pConnectionCount),
|
||||
external: 0,
|
||||
arrayBuffers: 0
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < p2pDomainEntries.length; i++) {
|
||||
try {
|
||||
const entry = p2pDomainEntries[i];
|
||||
const hash = hashResults[i];
|
||||
const startTime = state.holesailStartTimes && state.holesailStartTimes.get(entry.key);
|
||||
const uptime = startTime ? Date.now() - startTime : 0;
|
||||
|
||||
let timeRemaining = null;
|
||||
if (!entry.isPersistent && process.env.FULL_PERSISTENCE !== 'true' && startTime) {
|
||||
const timeoutDuration = parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '5');
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, timeoutDuration - elapsed);
|
||||
timeRemaining = remaining;
|
||||
}
|
||||
|
||||
holesailChildren.push({
|
||||
id: entry.key,
|
||||
type: 'p2p-domain',
|
||||
status: entry.status,
|
||||
pid: process.pid,
|
||||
uptime: uptime,
|
||||
timeRemaining: timeRemaining,
|
||||
cpuUsage: perConnectionCpu,
|
||||
memoryUsage: perConnectionMemory,
|
||||
opts: {
|
||||
domain: entry.domain,
|
||||
port: entry.port,
|
||||
host: entry.ip || null,
|
||||
protocol: 'tcp'
|
||||
},
|
||||
persistent: entry.isPersistent,
|
||||
hash: hash || null,
|
||||
info: entry.holesailInfo || null
|
||||
});
|
||||
} catch (err) {
|
||||
logError('Admin', `Error creating stats entry for p2p domain connection ${p2pDomainEntries[i].key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
stats.holesailChildren = holesailChildren;
|
||||
|
||||
// Collect Peer Channels stats
|
||||
const peerChannelsStats = collectPeerChannelsStats();
|
||||
stats.peerChannels = peerChannelsStats;
|
||||
|
||||
// Collect HyperDB stats
|
||||
const hyperdbStats = collectHyperDBStats();
|
||||
stats.hyperdb = hyperdbStats;
|
||||
|
||||
// Collect Hyperdrive stats
|
||||
const hyperdriveStats = collectHyperdriveStats();
|
||||
stats.hyperdrive = hyperdriveStats;
|
||||
|
||||
if (typeof state.diagnoseInviteIssues === 'function') {
|
||||
try {
|
||||
stats.core = state.diagnoseInviteIssues();
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Core RPC stats unavailable: ${err.message}`);
|
||||
stats.core = { initializing: true, error: err.message };
|
||||
}
|
||||
} else {
|
||||
stats.core = { initializing: true };
|
||||
}
|
||||
|
||||
const stats = await collectAdminStats();
|
||||
const responseTime = Date.now() - startTime;
|
||||
trackRequestWithTiming('/api/stats', true, responseTime);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -339,12 +34,10 @@ async function handleStatsRoutes(req, res) {
|
||||
const minutesParam = params.get('minutes');
|
||||
if (minutesParam) {
|
||||
minutes = parseInt(minutesParam, 10);
|
||||
if (isNaN(minutes) || minutes < 1) minutes = 60;
|
||||
if (minutes > 1440) minutes = 1440;
|
||||
}
|
||||
}
|
||||
const startTime = Date.now();
|
||||
const historical = getHistoricalData(minutes);
|
||||
const historical = collectHistoricalMinutes(minutes);
|
||||
const responseTime = Date.now() - startTime;
|
||||
trackRequestWithTiming('/api/stats/historical', true, responseTime);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -362,249 +55,4 @@ async function handleStatsRoutes(req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect comprehensive Peer Channels statistics
|
||||
*/
|
||||
function collectPeerChannelsStats() {
|
||||
const stats = {
|
||||
totalPlugins: 0,
|
||||
totalProtocols: 0,
|
||||
totalPeerConnections: 0,
|
||||
openChannels: 0,
|
||||
closedChannels: 0,
|
||||
plugins: []
|
||||
};
|
||||
|
||||
try {
|
||||
if (!state.pluginChannels || state.pluginChannels.size === 0) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
stats.totalPlugins = state.pluginChannels.size;
|
||||
|
||||
for (const [pluginDomain, protocolMap] of state.pluginChannels.entries()) {
|
||||
const handlers = state.pluginChannelHandlers?.get(pluginDomain);
|
||||
const pluginInfo = {
|
||||
domain: pluginDomain,
|
||||
protocols: []
|
||||
};
|
||||
|
||||
for (const [protocol, channelInfo] of protocolMap.entries()) {
|
||||
stats.totalProtocols++;
|
||||
const handler = handlers?.get(protocol);
|
||||
|
||||
const protocolInfo = {
|
||||
name: protocol,
|
||||
fullName: `${pluginDomain}-${protocol}`,
|
||||
encoding: handler?.encoding || 'unknown',
|
||||
autoReconnect: handler?.autoReconnect !== false,
|
||||
peerCount: 0,
|
||||
openCount: 0,
|
||||
closedCount: 0,
|
||||
peers: []
|
||||
};
|
||||
|
||||
if (channelInfo.peerChannels) {
|
||||
protocolInfo.peerCount = channelInfo.peerChannels.size;
|
||||
stats.totalPeerConnections += channelInfo.peerChannels.size;
|
||||
|
||||
for (const [peerId, peerChannel] of channelInfo.peerChannels.entries()) {
|
||||
const isOpen = peerChannel.channel?.opened || false;
|
||||
const isClosed = peerChannel.channel?.closed || false;
|
||||
|
||||
if (isOpen) {
|
||||
protocolInfo.openCount++;
|
||||
stats.openChannels++;
|
||||
} else {
|
||||
protocolInfo.closedCount++;
|
||||
stats.closedChannels++;
|
||||
}
|
||||
|
||||
const peerInfo = {
|
||||
peerId: peerId.substring(0, 16) + '...',
|
||||
fullPeerId: peerId,
|
||||
status: isClosed ? 'closed' : (isOpen ? 'open' : 'connecting'),
|
||||
localOpened: peerChannel.localOpened || false,
|
||||
remoteOpened: peerChannel.remoteOpened || false,
|
||||
openedAt: peerChannel.openedAt || null,
|
||||
closedAt: peerChannel.closedAt || null,
|
||||
lastRemoteOpen: peerChannel.lastRemoteOpen || null,
|
||||
reopenAttempts: peerChannel.reopenAttempts || 0,
|
||||
lastReopenAttempt: peerChannel.lastReopenAttempt || null,
|
||||
connectionValid: peerChannel.conn && !peerChannel.conn.destroyed,
|
||||
muxValid: !!peerChannel.mux
|
||||
};
|
||||
|
||||
protocolInfo.peers.push(peerInfo);
|
||||
}
|
||||
}
|
||||
|
||||
pluginInfo.protocols.push(protocolInfo);
|
||||
}
|
||||
|
||||
stats.plugins.push(pluginInfo);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting peer channels stats: ${err.message}`);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect comprehensive HyperDB statistics
|
||||
*/
|
||||
function collectHyperDBStats() {
|
||||
const stats = {
|
||||
totalDatabases: 0,
|
||||
totalStores: 0,
|
||||
replicationActive: 0,
|
||||
databases: []
|
||||
};
|
||||
|
||||
try {
|
||||
if (!dbManager) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Get all plugin stores
|
||||
const pluginStores = dbManager.getAllPluginStores ? dbManager.getAllPluginStores() : new Map();
|
||||
stats.totalStores = pluginStores.size;
|
||||
|
||||
for (const [pluginDomain, store] of pluginStores.entries()) {
|
||||
const dbInfo = {
|
||||
pluginDomain,
|
||||
pluginVersion: null,
|
||||
storeReady: false,
|
||||
coreKey: null,
|
||||
coreKeyShort: null,
|
||||
databaseOpen: false,
|
||||
replicationActive: false,
|
||||
writable: false,
|
||||
length: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Get plugin version from config
|
||||
try {
|
||||
const pluginHandler = require('../../../plugins/plugin-handler');
|
||||
const plugin = pluginHandler.getPlugin(pluginDomain);
|
||||
if (plugin && plugin.config && plugin.config.version) {
|
||||
dbInfo.pluginVersion = plugin.config.version;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore version lookup errors
|
||||
}
|
||||
|
||||
// Check store status
|
||||
if (store && !store.closed) {
|
||||
dbInfo.storeReady = true;
|
||||
}
|
||||
|
||||
// Get core key
|
||||
const coreKey = dbManager.getPluginCoreKey ? dbManager.getPluginCoreKey(pluginDomain) : null;
|
||||
if (coreKey) {
|
||||
dbInfo.coreKey = coreKey.toString('hex');
|
||||
dbInfo.coreKeyShort = coreKey.toString('hex').substring(0, 16) + '...';
|
||||
}
|
||||
|
||||
// Get core instance for more details
|
||||
const core = dbManager.getPluginCore ? dbManager.getPluginCore(pluginDomain) : null;
|
||||
if (core) {
|
||||
dbInfo.writable = core.writable || false;
|
||||
dbInfo.length = core.length || 0;
|
||||
}
|
||||
|
||||
// Get database instance
|
||||
const db = dbManager.getDatabaseInstance ? dbManager.getDatabaseInstance(pluginDomain) : null;
|
||||
if (db && !db.closed) {
|
||||
dbInfo.databaseOpen = true;
|
||||
stats.totalDatabases++;
|
||||
}
|
||||
|
||||
// Check replication status
|
||||
if (replicationManager && replicationManager.isReplicationActive) {
|
||||
dbInfo.replicationActive = replicationManager.isReplicationActive(pluginDomain);
|
||||
if (dbInfo.replicationActive) {
|
||||
stats.replicationActive++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Error getting HyperDB info for ${pluginDomain}: ${err.message}`);
|
||||
}
|
||||
|
||||
stats.databases.push(dbInfo);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting HyperDB stats: ${err.message}`);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect Hyperdrive statistics
|
||||
*/
|
||||
function collectHyperdriveStats() {
|
||||
const stats = {
|
||||
totalDrives: 0,
|
||||
replicationActive: 0,
|
||||
drives: []
|
||||
};
|
||||
|
||||
try {
|
||||
if (!driveManager) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Get all drives from drive manager
|
||||
const allDrives = driveManager.getAllDriveInstances ? driveManager.getAllDriveInstances() : new Map();
|
||||
stats.totalDrives = allDrives.size;
|
||||
|
||||
for (const [driveKey, driveInfo] of allDrives.entries()) {
|
||||
const { pluginDomain, driveName, drive } = driveInfo;
|
||||
const info = {
|
||||
pluginDomain,
|
||||
driveName,
|
||||
driveReady: false,
|
||||
discoveryKey: null,
|
||||
discoveryKeyShort: null,
|
||||
replicationActive: false,
|
||||
writable: false,
|
||||
version: 0
|
||||
};
|
||||
|
||||
try {
|
||||
if (drive) {
|
||||
info.driveReady = !drive.closed;
|
||||
info.writable = drive.writable || false;
|
||||
info.version = drive.version || 0;
|
||||
|
||||
if (drive.discoveryKey) {
|
||||
info.discoveryKey = drive.discoveryKey.toString('hex');
|
||||
info.discoveryKeyShort = drive.discoveryKey.toString('hex').substring(0, 16) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Check replication status
|
||||
if (driveReplicationManager && driveReplicationManager.isReplicationActive) {
|
||||
info.replicationActive = driveReplicationManager.isReplicationActive(pluginDomain, driveName);
|
||||
if (info.replicationActive) {
|
||||
stats.replicationActive++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Error getting Hyperdrive info for ${pluginDomain}/${driveName}: ${err.message}`);
|
||||
}
|
||||
|
||||
stats.drives.push(info);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting Hyperdrive stats: ${err.message}`);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
module.exports = { handleStatsRoutes };
|
||||
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
// Shared admin stats collection (HTTP + WebSocket)
|
||||
const dgram = require('dgram');
|
||||
const state = require('../../infrastructure/state');
|
||||
const { getMetrics, getHistoricalData } = require('../../maintenance/metrics');
|
||||
const { getHashForDomain } = require('../../core/core');
|
||||
const { logDebug, logError } = require('../../infrastructure/logger');
|
||||
const { parseMinutesToMs } = require('../../infrastructure/utils');
|
||||
const pidusage = require('pidusage');
|
||||
|
||||
// Import db-manager and replication-manager for HyperDB stats
|
||||
let dbManager = null;
|
||||
let replicationManager = null;
|
||||
let driveReplicationManager = null;
|
||||
let driveManager = null;
|
||||
try {
|
||||
dbManager = require('../../plugins/db-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'db-manager not available for stats');
|
||||
}
|
||||
try {
|
||||
replicationManager = require('../../plugins/replication-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'replication-manager not available for stats');
|
||||
}
|
||||
try {
|
||||
driveReplicationManager = require('../../plugins/drive-replication-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'drive-replication-manager not available for stats');
|
||||
}
|
||||
try {
|
||||
driveManager = require('../../plugins/drive-manager');
|
||||
} catch (e) {
|
||||
logDebug('Admin', 'drive-manager not available for stats');
|
||||
}
|
||||
|
||||
async function collectAdminStats() {
|
||||
const stats = getMetrics();
|
||||
if (state.hypercoreStats && typeof state.hypercoreStats.toJson === 'function') {
|
||||
try {
|
||||
const { normalizeHolepunchStats } = require('../../infrastructure/holepunch-stats-schema');
|
||||
stats.holepunch = normalizeHolepunchStats(state.hypercoreStats.toJson());
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Failed to collect hypercore stats: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const holesailChildren = [];
|
||||
const pidStatsPromises = [];
|
||||
const pidToChildMap = new Map();
|
||||
|
||||
for (const [id, child] of state.holesailChildren.entries()) {
|
||||
try {
|
||||
const opts = state.holesailOpts.get(id) || {};
|
||||
const info = state.holesailInfos.get(id) || {};
|
||||
const startTime = state.holesailChildStartTimes.get(id);
|
||||
const uptime = startTime ? Date.now() - startTime : 0;
|
||||
const status = child && !child.killed ? 'running' : 'stopped';
|
||||
const pid = child ? child.pid : null;
|
||||
|
||||
if (pid && child && !child.killed) {
|
||||
pidStatsPromises.push(
|
||||
pidusage(pid).then(stats => ({ id, type: 'server', stats })).catch(err => {
|
||||
logDebug('Admin', `Failed to get stats for server child ${id} (PID ${pid}): ${err.message}`);
|
||||
return { id, type: 'server', stats: null };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
pidToChildMap.set(id, {
|
||||
id,
|
||||
type: 'server',
|
||||
status,
|
||||
pid,
|
||||
uptime,
|
||||
cpuUsage: null,
|
||||
memoryUsage: null,
|
||||
opts: {
|
||||
port: opts.port,
|
||||
host: opts.host || '0.0.0.0',
|
||||
protocol: opts.udp ? 'udp' : 'tcp',
|
||||
secure: opts.secure || false,
|
||||
domain: opts.domain || null
|
||||
},
|
||||
info: info
|
||||
});
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting stats for server child ${id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, child] of state.holesailClientChildren.entries()) {
|
||||
try {
|
||||
const opts = state.holesailClientOpts.get(id) || {};
|
||||
const info = state.holesailClientInfos.get(id) || {};
|
||||
const startTime = state.holesailChildStartTimes.get(id);
|
||||
const uptime = startTime ? Date.now() - startTime : 0;
|
||||
const status = child && !child.killed ? 'running' : 'stopped';
|
||||
const pid = child ? child.pid : null;
|
||||
|
||||
if (pid && child && !child.killed) {
|
||||
pidStatsPromises.push(
|
||||
pidusage(pid).then(stats => ({ id, type: 'client', stats })).catch(err => {
|
||||
logDebug('Admin', `Failed to get stats for client child ${id} (PID ${pid}): ${err.message}`);
|
||||
return { id, type: 'client', stats: null };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
pidToChildMap.set(id, {
|
||||
id,
|
||||
type: 'client',
|
||||
status,
|
||||
pid,
|
||||
uptime,
|
||||
cpuUsage: null,
|
||||
memoryUsage: null,
|
||||
opts: {
|
||||
domain: opts.domain || null,
|
||||
port: opts.port,
|
||||
host: opts.host || null,
|
||||
protocol: opts.protocol || 'tcp'
|
||||
},
|
||||
info: info
|
||||
});
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting stats for client child ${id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pidStatsResults = await Promise.all(pidStatsPromises);
|
||||
|
||||
for (const result of pidStatsResults) {
|
||||
if (result.stats) {
|
||||
const childData = pidToChildMap.get(result.id);
|
||||
if (childData) {
|
||||
childData.cpuUsage = {
|
||||
user: result.stats.cpu / 2,
|
||||
system: result.stats.cpu / 2,
|
||||
percentage: result.stats.cpu
|
||||
};
|
||||
childData.memoryUsage = {
|
||||
rss: result.stats.memory,
|
||||
heapUsed: result.stats.memory * 0.8,
|
||||
heapTotal: result.stats.memory,
|
||||
external: 0,
|
||||
arrayBuffers: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const childData of pidToChildMap.values()) {
|
||||
holesailChildren.push(childData);
|
||||
}
|
||||
|
||||
const managedConnections = new Set();
|
||||
for (const child of holesailChildren) {
|
||||
if (child.opts && child.opts.domain && child.opts.port) {
|
||||
managedConnections.add(`${child.opts.domain}:${child.opts.port}`);
|
||||
}
|
||||
}
|
||||
|
||||
const p2pDomainEntries = [];
|
||||
const hashPromises = [];
|
||||
|
||||
for (const [key, holesail] of state.holesails.entries()) {
|
||||
try {
|
||||
if (managedConnections.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyParts = key.split(':');
|
||||
if (keyParts.length !== 2) {
|
||||
logDebug('Admin', `Skipping invalid holesail key format: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const domain = keyParts[0];
|
||||
const port = parseInt(keyParts[1], 10);
|
||||
|
||||
if (isNaN(port)) {
|
||||
logDebug('Admin', `Skipping holesail key with invalid port: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ip = state.domainToIPMap.get ? state.domainToIPMap.get(domain) : state.domainToIPMap[domain];
|
||||
const isPersistent = state.persistentConnections && state.persistentConnections.has(key);
|
||||
const status = holesail && typeof holesail === 'object' ? 'running' : 'stopped';
|
||||
|
||||
let holesailInfo = null;
|
||||
try {
|
||||
if (holesail && typeof holesail === 'object' && holesail.info) {
|
||||
holesailInfo = holesail.info;
|
||||
}
|
||||
} catch (e) {
|
||||
// Info not available
|
||||
}
|
||||
|
||||
p2pDomainEntries.push({
|
||||
key,
|
||||
domain,
|
||||
port,
|
||||
ip,
|
||||
isPersistent,
|
||||
status,
|
||||
holesailInfo
|
||||
});
|
||||
|
||||
hashPromises.push(
|
||||
getHashForDomain(domain).catch(err => {
|
||||
logDebug('Admin', `Could not get hash for domain ${domain}: ${err.message}`);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
logError('Admin', `Error processing p2p domain connection ${key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hashResults = await Promise.all(hashPromises);
|
||||
|
||||
let mainProcessStats = null;
|
||||
try {
|
||||
mainProcessStats = await pidusage(process.pid);
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Failed to get main process stats for p2p connections: ${err.message}`);
|
||||
}
|
||||
|
||||
const p2pConnectionCount = p2pDomainEntries.length || 1;
|
||||
let perConnectionCpu = null;
|
||||
let perConnectionMemory = null;
|
||||
|
||||
if (mainProcessStats) {
|
||||
perConnectionCpu = {
|
||||
user: mainProcessStats.cpu / (p2pConnectionCount * 2),
|
||||
system: mainProcessStats.cpu / (p2pConnectionCount * 2),
|
||||
percentage: mainProcessStats.cpu / p2pConnectionCount
|
||||
};
|
||||
|
||||
perConnectionMemory = {
|
||||
rss: Math.floor(mainProcessStats.memory / p2pConnectionCount),
|
||||
heapUsed: Math.floor((mainProcessStats.memory * 0.8) / p2pConnectionCount),
|
||||
heapTotal: Math.floor(mainProcessStats.memory / p2pConnectionCount),
|
||||
external: 0,
|
||||
arrayBuffers: 0
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < p2pDomainEntries.length; i++) {
|
||||
try {
|
||||
const entry = p2pDomainEntries[i];
|
||||
const hash = hashResults[i];
|
||||
const startTime = state.holesailStartTimes && state.holesailStartTimes.get(entry.key);
|
||||
const uptime = startTime ? Date.now() - startTime : 0;
|
||||
|
||||
let timeRemaining = null;
|
||||
if (!entry.isPersistent && process.env.FULL_PERSISTENCE !== 'true' && startTime) {
|
||||
const timeoutDuration = parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '5');
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, timeoutDuration - elapsed);
|
||||
timeRemaining = remaining;
|
||||
}
|
||||
|
||||
holesailChildren.push({
|
||||
id: entry.key,
|
||||
type: 'p2p-domain',
|
||||
status: entry.status,
|
||||
pid: process.pid,
|
||||
uptime: uptime,
|
||||
timeRemaining: timeRemaining,
|
||||
cpuUsage: perConnectionCpu,
|
||||
memoryUsage: perConnectionMemory,
|
||||
opts: {
|
||||
domain: entry.domain,
|
||||
port: entry.port,
|
||||
host: entry.ip || null,
|
||||
protocol: 'tcp'
|
||||
},
|
||||
persistent: entry.isPersistent,
|
||||
hash: hash || null,
|
||||
info: entry.holesailInfo || null
|
||||
});
|
||||
} catch (err) {
|
||||
logError('Admin', `Error creating stats entry for p2p domain connection ${p2pDomainEntries[i].key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
stats.holesailChildren = holesailChildren;
|
||||
|
||||
// Collect Peer Channels stats
|
||||
const peerChannelsStats = collectPeerChannelsStats();
|
||||
stats.peerChannels = peerChannelsStats;
|
||||
|
||||
// Collect HyperDB stats
|
||||
const hyperdbStats = collectHyperDBStats();
|
||||
stats.hyperdb = hyperdbStats;
|
||||
|
||||
// Collect Hyperdrive stats
|
||||
const hyperdriveStats = collectHyperdriveStats();
|
||||
stats.hyperdrive = hyperdriveStats;
|
||||
|
||||
if (typeof state.diagnoseInviteIssuesAsync === 'function') {
|
||||
try {
|
||||
stats.core = await state.diagnoseInviteIssuesAsync();
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Core RPC stats unavailable: ${err.message}`);
|
||||
stats.core = { initializing: true, error: err.message };
|
||||
}
|
||||
} else if (typeof state.diagnoseInviteIssues === 'function') {
|
||||
try {
|
||||
stats.core = state.diagnoseInviteIssues();
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Core RPC stats unavailable: ${err.message}`);
|
||||
stats.core = { initializing: true, error: err.message };
|
||||
}
|
||||
} else {
|
||||
stats.core = { initializing: true };
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function collectHistoricalMinutes(minutes) {
|
||||
let m = parseInt(minutes, 10);
|
||||
if (isNaN(m) || m < 1) m = 60;
|
||||
if (m > 1440) m = 1440;
|
||||
return getHistoricalData(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect comprehensive Peer Channels statistics
|
||||
*/
|
||||
function collectPeerChannelsStats() {
|
||||
const stats = {
|
||||
totalPlugins: 0,
|
||||
totalProtocols: 0,
|
||||
totalPeerConnections: 0,
|
||||
openChannels: 0,
|
||||
closedChannels: 0,
|
||||
plugins: []
|
||||
};
|
||||
|
||||
try {
|
||||
if (!state.pluginChannels || state.pluginChannels.size === 0) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
stats.totalPlugins = state.pluginChannels.size;
|
||||
|
||||
for (const [pluginDomain, protocolMap] of state.pluginChannels.entries()) {
|
||||
const handlers = state.pluginChannelHandlers?.get(pluginDomain);
|
||||
const pluginInfo = {
|
||||
domain: pluginDomain,
|
||||
protocols: []
|
||||
};
|
||||
|
||||
for (const [protocol, channelInfo] of protocolMap.entries()) {
|
||||
stats.totalProtocols++;
|
||||
const handler = handlers?.get(protocol);
|
||||
|
||||
const protocolInfo = {
|
||||
name: protocol,
|
||||
fullName: `${pluginDomain}-${protocol}`,
|
||||
encoding: handler?.encoding || 'unknown',
|
||||
autoReconnect: handler?.autoReconnect !== false,
|
||||
peerCount: 0,
|
||||
openCount: 0,
|
||||
closedCount: 0,
|
||||
peers: []
|
||||
};
|
||||
|
||||
if (channelInfo.peerChannels) {
|
||||
protocolInfo.peerCount = channelInfo.peerChannels.size;
|
||||
stats.totalPeerConnections += channelInfo.peerChannels.size;
|
||||
|
||||
for (const [peerId, peerChannel] of channelInfo.peerChannels.entries()) {
|
||||
const isOpen = peerChannel.channel?.opened || false;
|
||||
const isClosed = peerChannel.channel?.closed || false;
|
||||
|
||||
if (isOpen) {
|
||||
protocolInfo.openCount++;
|
||||
stats.openChannels++;
|
||||
} else {
|
||||
protocolInfo.closedCount++;
|
||||
stats.closedChannels++;
|
||||
}
|
||||
|
||||
const peerInfo = {
|
||||
peerId: peerId.substring(0, 16) + '...',
|
||||
fullPeerId: peerId,
|
||||
status: isClosed ? 'closed' : (isOpen ? 'open' : 'connecting'),
|
||||
localOpened: peerChannel.localOpened || false,
|
||||
remoteOpened: peerChannel.remoteOpened || false,
|
||||
openedAt: peerChannel.openedAt || null,
|
||||
closedAt: peerChannel.closedAt || null,
|
||||
lastRemoteOpen: peerChannel.lastRemoteOpen || null,
|
||||
reopenAttempts: peerChannel.reopenAttempts || 0,
|
||||
lastReopenAttempt: peerChannel.lastReopenAttempt || null,
|
||||
connectionValid: peerChannel.conn && !peerChannel.conn.destroyed,
|
||||
muxValid: !!peerChannel.mux
|
||||
};
|
||||
|
||||
protocolInfo.peers.push(peerInfo);
|
||||
}
|
||||
}
|
||||
|
||||
pluginInfo.protocols.push(protocolInfo);
|
||||
}
|
||||
|
||||
stats.plugins.push(pluginInfo);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting peer channels stats: ${err.message}`);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect comprehensive HyperDB statistics
|
||||
*/
|
||||
function collectHyperDBStats() {
|
||||
const stats = {
|
||||
totalDatabases: 0,
|
||||
totalStores: 0,
|
||||
replicationActive: 0,
|
||||
databases: []
|
||||
};
|
||||
|
||||
try {
|
||||
if (!dbManager) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Get all plugin stores
|
||||
const pluginStores = dbManager.getAllPluginStores ? dbManager.getAllPluginStores() : new Map();
|
||||
stats.totalStores = pluginStores.size;
|
||||
|
||||
for (const [pluginDomain, store] of pluginStores.entries()) {
|
||||
const dbInfo = {
|
||||
pluginDomain,
|
||||
pluginVersion: null,
|
||||
storeReady: false,
|
||||
coreKey: null,
|
||||
coreKeyShort: null,
|
||||
databaseOpen: false,
|
||||
replicationActive: false,
|
||||
writable: false,
|
||||
length: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Get plugin version from config
|
||||
try {
|
||||
const pluginHandler = require('../../plugins/plugin-handler');
|
||||
const plugin = pluginHandler.getPlugin(pluginDomain);
|
||||
if (plugin && plugin.config && plugin.config.version) {
|
||||
dbInfo.pluginVersion = plugin.config.version;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore version lookup errors
|
||||
}
|
||||
|
||||
// Check store status
|
||||
if (store && !store.closed) {
|
||||
dbInfo.storeReady = true;
|
||||
}
|
||||
|
||||
// Get core key
|
||||
const coreKey = dbManager.getPluginCoreKey ? dbManager.getPluginCoreKey(pluginDomain) : null;
|
||||
if (coreKey) {
|
||||
dbInfo.coreKey = coreKey.toString('hex');
|
||||
dbInfo.coreKeyShort = coreKey.toString('hex').substring(0, 16) + '...';
|
||||
}
|
||||
|
||||
// Get core instance for more details
|
||||
const core = dbManager.getPluginCore ? dbManager.getPluginCore(pluginDomain) : null;
|
||||
if (core) {
|
||||
dbInfo.writable = core.writable || false;
|
||||
dbInfo.length = core.length || 0;
|
||||
}
|
||||
|
||||
// Get database instance
|
||||
const db = dbManager.getDatabaseInstance ? dbManager.getDatabaseInstance(pluginDomain) : null;
|
||||
if (db && !db.closed) {
|
||||
dbInfo.databaseOpen = true;
|
||||
stats.totalDatabases++;
|
||||
}
|
||||
|
||||
// Check replication status
|
||||
if (replicationManager && replicationManager.isReplicationActive) {
|
||||
dbInfo.replicationActive = replicationManager.isReplicationActive(pluginDomain);
|
||||
if (dbInfo.replicationActive) {
|
||||
stats.replicationActive++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Error getting HyperDB info for ${pluginDomain}: ${err.message}`);
|
||||
}
|
||||
|
||||
stats.databases.push(dbInfo);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting HyperDB stats: ${err.message}`);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect Hyperdrive statistics
|
||||
*/
|
||||
function collectHyperdriveStats() {
|
||||
const stats = {
|
||||
totalDrives: 0,
|
||||
replicationActive: 0,
|
||||
drives: []
|
||||
};
|
||||
|
||||
try {
|
||||
if (!driveManager) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Get all drives from drive manager
|
||||
const allDrives = driveManager.getAllDriveInstances ? driveManager.getAllDriveInstances() : new Map();
|
||||
stats.totalDrives = allDrives.size;
|
||||
|
||||
for (const [driveKey, driveInfo] of allDrives.entries()) {
|
||||
const { pluginDomain, driveName, drive } = driveInfo;
|
||||
const info = {
|
||||
pluginDomain,
|
||||
driveName,
|
||||
driveReady: false,
|
||||
discoveryKey: null,
|
||||
discoveryKeyShort: null,
|
||||
replicationActive: false,
|
||||
writable: false,
|
||||
version: 0
|
||||
};
|
||||
|
||||
try {
|
||||
if (drive) {
|
||||
info.driveReady = !drive.closed;
|
||||
info.writable = drive.writable || false;
|
||||
info.version = drive.version || 0;
|
||||
|
||||
if (drive.discoveryKey) {
|
||||
info.discoveryKey = drive.discoveryKey.toString('hex');
|
||||
info.discoveryKeyShort = drive.discoveryKey.toString('hex').substring(0, 16) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Check replication status
|
||||
if (driveReplicationManager && driveReplicationManager.isReplicationActive) {
|
||||
info.replicationActive = driveReplicationManager.isReplicationActive(pluginDomain, driveName);
|
||||
if (info.replicationActive) {
|
||||
stats.replicationActive++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Error getting Hyperdrive info for ${pluginDomain}/${driveName}: ${err.message}`);
|
||||
}
|
||||
|
||||
stats.drives.push(info);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error collecting Hyperdrive stats: ${err.message}`);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
module.exports = { collectAdminStats, collectHistoricalMinutes };
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* WebSocket push for admin stats tab (avoids polling /api/stats over HTTPS).
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const { logDebug, logError } = require('../../infrastructure/logger');
|
||||
const { buildStatsPageSnapshot } = require('./admin-snapshot');
|
||||
|
||||
/** @type {Map<import('ws'), { minutes: number, intervalMs: number }>} */
|
||||
const statsSubscribers = new Map();
|
||||
/** @type {ReturnType<typeof setInterval>|null} */
|
||||
let statsBroadcastInterval = null;
|
||||
let statsBroadcastInFlight = false;
|
||||
let statsBroadcastIntervalMs = 0;
|
||||
|
||||
const DEFAULT_MINUTES = 1440;
|
||||
const DEFAULT_INTERVAL_MS = 5000;
|
||||
const MIN_INTERVAL_MS = 1000;
|
||||
|
||||
function normalizeIntervalMs(value) {
|
||||
const envDefault = parseInt(process.env.ADMIN_STATS_WS_INTERVAL_MS || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const fallback = Number.isFinite(envDefault) && envDefault >= MIN_INTERVAL_MS ? envDefault : DEFAULT_INTERVAL_MS;
|
||||
const n = parseInt(value, 10);
|
||||
if (!Number.isFinite(n) || n < MIN_INTERVAL_MS) return fallback;
|
||||
return n;
|
||||
}
|
||||
|
||||
function getMinSubscriberIntervalMs() {
|
||||
if (statsSubscribers.size === 0) return normalizeIntervalMs();
|
||||
let min = Infinity;
|
||||
for (const sub of statsSubscribers.values()) {
|
||||
if (sub.intervalMs < min) min = sub.intervalMs;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
function parseMinutes(value) {
|
||||
const m = parseInt(value, 10);
|
||||
if (isNaN(m) || m < 1) return DEFAULT_MINUTES;
|
||||
if (m > 1440) return 1440;
|
||||
return m;
|
||||
}
|
||||
|
||||
function sendToClient(ws, payload) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
async function pushStatsSnapshot(ws) {
|
||||
const sub = statsSubscribers.get(ws);
|
||||
if (!sub) return;
|
||||
try {
|
||||
const snapshot = await buildStatsPageSnapshot(sub.minutes);
|
||||
sendToClient(ws, snapshot);
|
||||
} catch (err) {
|
||||
logError('Admin', `Stats snapshot failed: ${err.message}`);
|
||||
sendToClient(ws, { type: 'stats-snapshot-error', error: err.message, timestamp: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
async function broadcastStatsSnapshots() {
|
||||
if (statsSubscribers.size === 0 || statsBroadcastInFlight) return;
|
||||
statsBroadcastInFlight = true;
|
||||
try {
|
||||
await Promise.all([...statsSubscribers.keys()].map((ws) => pushStatsSnapshot(ws)));
|
||||
} finally {
|
||||
statsBroadcastInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function restartStatsBroadcastLoop() {
|
||||
const nextMs = getMinSubscriberIntervalMs();
|
||||
if (statsBroadcastInterval && statsBroadcastIntervalMs === nextMs) return;
|
||||
stopStatsBroadcastLoop();
|
||||
if (statsSubscribers.size === 0) return;
|
||||
statsBroadcastIntervalMs = nextMs;
|
||||
statsBroadcastInterval = setInterval(() => {
|
||||
broadcastStatsSnapshots().catch((err) => {
|
||||
logError('Admin', `Stats broadcast loop error: ${err.message}`);
|
||||
});
|
||||
}, nextMs);
|
||||
logDebug('Admin', `Stats WebSocket broadcast loop started (${nextMs}ms)`);
|
||||
}
|
||||
|
||||
function stopStatsBroadcastLoop() {
|
||||
if (statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
statsBroadcastInterval = null;
|
||||
statsBroadcastIntervalMs = 0;
|
||||
logDebug('Admin', 'Stats WebSocket broadcast loop stopped');
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeStats(ws, minutes, intervalMs) {
|
||||
const existing = statsSubscribers.get(ws);
|
||||
statsSubscribers.set(ws, {
|
||||
minutes: parseMinutes(minutes),
|
||||
intervalMs: normalizeIntervalMs(intervalMs ?? existing?.intervalMs)
|
||||
});
|
||||
restartStatsBroadcastLoop();
|
||||
pushStatsSnapshot(ws).catch((err) => logError('Admin', `Initial stats snapshot failed: ${err.message}`));
|
||||
}
|
||||
|
||||
function unsubscribeStats(ws) {
|
||||
statsSubscribers.delete(ws);
|
||||
if (statsSubscribers.size === 0) {
|
||||
stopStatsBroadcastLoop();
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatsWsMessage(ws, data) {
|
||||
if (data.type === 'subscribe-stats') {
|
||||
subscribeStats(ws, data.minutes, data.intervalMs);
|
||||
return true;
|
||||
}
|
||||
if (data.type === 'unsubscribe-stats') {
|
||||
unsubscribeStats(ws);
|
||||
return true;
|
||||
}
|
||||
if (data.type === 'request-stats-snapshot') {
|
||||
const sub = statsSubscribers.get(ws);
|
||||
if (sub && data.minutes != null) {
|
||||
sub.minutes = parseMinutes(data.minutes);
|
||||
} else if (!sub) {
|
||||
subscribeStats(ws, data.minutes);
|
||||
return true;
|
||||
}
|
||||
pushStatsSnapshot(ws).catch((err) => logError('Admin', `Stats snapshot request failed: ${err.message}`));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onStatsWsClose(ws) {
|
||||
unsubscribeStats(ws);
|
||||
}
|
||||
|
||||
function notifyStatsSubscribers() {
|
||||
if (statsSubscribers.size === 0) return;
|
||||
broadcastStatsSnapshots().catch((err) => {
|
||||
logError('Admin', `Stats notify failed: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function closeStatsWebSocketState() {
|
||||
statsSubscribers.clear();
|
||||
stopStatsBroadcastLoop();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleStatsWsMessage,
|
||||
onStatsWsClose,
|
||||
notifyStatsSubscribers,
|
||||
closeStatsWebSocketState,
|
||||
subscribeStats,
|
||||
unsubscribeStats
|
||||
};
|
||||
@@ -4,6 +4,13 @@ const state = require('../../infrastructure/state');
|
||||
const { metrics } = require('../../maintenance/metrics');
|
||||
const { getAllEntries, getHashForDomain, getConsensusState } = require('../../core/core');
|
||||
const { getPersistentPublicKey } = require('../../infrastructure/utils');
|
||||
const { getAdminStatusPayload } = require('./admin-snapshot');
|
||||
const {
|
||||
handleStatsWsMessage,
|
||||
onStatsWsClose,
|
||||
notifyStatsSubscribers,
|
||||
closeStatsWebSocketState
|
||||
} = require('./stats-websocket');
|
||||
|
||||
const adminWss = new WebSocket.Server({ noServer: true });
|
||||
const adminClients = new Set();
|
||||
@@ -110,6 +117,8 @@ adminWss.on('connection', (ws) => {
|
||||
}).catch(err => {
|
||||
logError('Admin', `Error sending requested domains list: ${err.message}`);
|
||||
});
|
||||
} else if (handleStatsWsMessage(ws, data)) {
|
||||
// subscribe-stats / unsubscribe-stats / request-stats-snapshot
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error handling WebSocket message: ${err.message}`);
|
||||
@@ -118,6 +127,7 @@ adminWss.on('connection', (ws) => {
|
||||
|
||||
// Handle close event
|
||||
ws.on('close', () => {
|
||||
onStatsWsClose(ws);
|
||||
adminClients.delete(ws);
|
||||
logDebug('Admin', 'WebSocket client disconnected');
|
||||
});
|
||||
@@ -125,6 +135,7 @@ adminWss.on('connection', (ws) => {
|
||||
// Handle error event to ensure cleanup
|
||||
ws.on('error', (err) => {
|
||||
logError('Admin', `WebSocket error: ${err.message}`);
|
||||
onStatsWsClose(ws);
|
||||
adminClients.delete(ws);
|
||||
try {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
@@ -143,6 +154,11 @@ adminWss.on('connection', (ws) => {
|
||||
});
|
||||
|
||||
function broadcast(msg) {
|
||||
if (msg.type === 'update-stats') {
|
||||
notifyStatsSubscribers();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const client of adminClients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(msg));
|
||||
@@ -169,7 +185,8 @@ function closeAllWebSockets() {
|
||||
}
|
||||
}
|
||||
adminClients.clear();
|
||||
|
||||
closeStatsWebSocketState();
|
||||
|
||||
// Stop health broadcasts
|
||||
stopHealthBroadcasts();
|
||||
|
||||
@@ -225,6 +242,7 @@ function broadcastHealth() {
|
||||
status: allServicesHealthy ? 'healthy' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Date.now() - (metrics?.startTime || Date.now()),
|
||||
nodeStatus: getAdminStatusPayload(),
|
||||
services: {
|
||||
dns: { healthy: dnsHealthy, enabled: process.env.DISABLE_DNS_SERVER !== 'true' },
|
||||
proxy: { healthy: proxyHealthy, enabled: process.env.DISABLE_PROXY_SERVER !== 'true' },
|
||||
|
||||
@@ -227,7 +227,8 @@ function initializeApp() {
|
||||
if (refreshIntervalSelector) {
|
||||
refreshIntervalSelector.addEventListener('change', () => {
|
||||
if (window.activeTab === 'stats' && autoRefreshCheckbox && autoRefreshCheckbox.checked) {
|
||||
if (window.startStatsUpdates) window.startStatsUpdates(); // Restart with new interval
|
||||
if (window.subscribeStatsWebSocket) window.subscribeStatsWebSocket();
|
||||
else if (window.startStatsUpdates) window.startStatsUpdates();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -236,7 +237,11 @@ function initializeApp() {
|
||||
if (timeRangeSelector) {
|
||||
timeRangeSelector.addEventListener('change', () => {
|
||||
if (window.activeTab === 'stats') {
|
||||
if (window.renderStats) window.renderStats();
|
||||
if (window.requestStatsSnapshotViaWebSocket) {
|
||||
window.requestStatsSnapshotViaWebSocket();
|
||||
} else if (window.renderStats) {
|
||||
window.renderStats();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,6 +65,9 @@ function connectWebSocket() {
|
||||
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
||||
window.ws.send(JSON.stringify({ type: 'request-domains' }));
|
||||
}
|
||||
if (window.activeTab === 'stats' && window.subscribeStatsWebSocket) {
|
||||
window.subscribeStatsWebSocket();
|
||||
}
|
||||
};
|
||||
|
||||
window.ws.onclose = () => {
|
||||
@@ -160,8 +163,21 @@ function connectWebSocket() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === 'update-stats' && window.activeTab === 'stats') {
|
||||
if (window.renderStats) window.renderStats();
|
||||
if (data.type === 'stats-snapshot') {
|
||||
if (window.activeTab === 'stats' && window.applyStatsSnapshot) {
|
||||
window.applyStatsSnapshot(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === 'update-health') {
|
||||
if (data.nodeStatus) {
|
||||
window.latestStatusPayload = data.nodeStatus;
|
||||
if (window.applyStatusPayload) window.applyStatusPayload(data.nodeStatus);
|
||||
else updateStatus();
|
||||
}
|
||||
if (window.activeTab === 'stats' && window.applyHealthPayload) {
|
||||
window.applyHealthPayload(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === 'update-plugins' && window.activeTab === 'plugins') {
|
||||
@@ -292,44 +308,49 @@ function cleanupWebSocket() {
|
||||
window.wsConnected = false;
|
||||
}
|
||||
|
||||
function applyStatusPayload(data) {
|
||||
if (!data) return;
|
||||
window.latestStatusPayload = data;
|
||||
let text;
|
||||
let color = 'bg-blue-600';
|
||||
if (data.isShuttingDown) {
|
||||
text = 'Gracefully Cleaning....';
|
||||
color = 'bg-orange-500';
|
||||
} else if (data.isMaster) {
|
||||
text = `This is Master • Peers: ${data.peersCount}`;
|
||||
} else if (data.isConnected) {
|
||||
text = `Connected to Master • Peers: ${data.peersCount}`;
|
||||
color = 'bg-blue-500';
|
||||
} else if (data.peersCount > 0) {
|
||||
text = 'Requesting access...';
|
||||
color = 'bg-blue-300';
|
||||
} else {
|
||||
text = 'Searching for peers...';
|
||||
color = 'bg-blue-300';
|
||||
}
|
||||
if (!window.wsConnected) {
|
||||
text += ' (Polling)';
|
||||
color = 'bg-yellow-500';
|
||||
}
|
||||
const indicator = document.getElementById('status-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = text;
|
||||
indicator.className = `px-4 py-2 rounded-lg status-indicator-glass ${color}`;
|
||||
indicator.style.color = 'var(--text-primary)';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
if (window.wsConnected && window.latestStatusPayload) {
|
||||
applyStatusPayload(window.latestStatusPayload);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
const data = await res.json();
|
||||
let text;
|
||||
let color = 'bg-blue-600';
|
||||
if (data.isShuttingDown) {
|
||||
text = 'Gracefully Cleaning....';
|
||||
color = 'bg-orange-500';
|
||||
} else if (data.isMaster) {
|
||||
text = `This is Master • Peers: ${data.peersCount}`;
|
||||
} else {
|
||||
if (data.isConnected) {
|
||||
text = `Connected to Master • Peers: ${data.peersCount}`;
|
||||
color = 'bg-blue-500';
|
||||
} else {
|
||||
if (data.peersCount > 0) {
|
||||
text = 'Requesting access...';
|
||||
color = 'bg-blue-300';
|
||||
} else {
|
||||
text = 'Searching for peers...';
|
||||
color = 'bg-blue-300';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!window.wsConnected) {
|
||||
text += ' (Polling)';
|
||||
color = 'bg-yellow-500';
|
||||
}
|
||||
const indicator = document.getElementById('status-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = text;
|
||||
indicator.className = `px-4 py-2 rounded-lg status-indicator-glass ${color}`;
|
||||
indicator.style.color = 'var(--text-primary)';
|
||||
}
|
||||
applyStatusPayload(await res.json());
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch status:', err);
|
||||
const indicator = document.getElementById('status-indicator');
|
||||
@@ -345,7 +366,11 @@ function startStatusUpdates() {
|
||||
if (window.statusUpdateInterval) {
|
||||
clearInterval(window.statusUpdateInterval);
|
||||
}
|
||||
window.statusUpdateInterval = setInterval(updateStatus, 5000);
|
||||
updateStatus();
|
||||
// Fallback HTTP poll only when WebSocket is disconnected
|
||||
window.statusUpdateInterval = setInterval(() => {
|
||||
if (!window.wsConnected) updateStatus();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopStatusUpdates() {
|
||||
@@ -437,6 +462,7 @@ window.startPollingFallback = startPollingFallback;
|
||||
window.stopPollingFallback = stopPollingFallback;
|
||||
window.cleanupWebSocket = cleanupWebSocket;
|
||||
window.updateStatus = updateStatus;
|
||||
window.applyStatusPayload = applyStatusPayload;
|
||||
window.startStatusUpdates = startStatusUpdates;
|
||||
window.stopStatusUpdates = stopStatusUpdates;
|
||||
window.startDomainsUpdates = startDomainsUpdates;
|
||||
|
||||
@@ -18,7 +18,8 @@ const METHODS = {
|
||||
INVITE_RELAY_RESPONSE: 'invite.relayResponse',
|
||||
CONSENSUS_REMOVE_DOMAIN: 'consensus.removeDomain',
|
||||
CONSENSUS_RECALCULATE: 'consensus.recalculate',
|
||||
CONSENSUS_REMOVE_CONFLICT_CLAIM: 'consensus.removeConflictClaim'
|
||||
CONSENSUS_REMOVE_CONFLICT_CLAIM: 'consensus.removeConflictClaim',
|
||||
CORE_STATUS: 'core.status'
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -33,6 +33,20 @@ async function inviteRequest(peerId, timeoutMs = 5000) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query remote peer's self-reported core / invite state.
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function coreStatusRequest(peerId, timeoutMs = 3000) {
|
||||
const rpc = getRequestRpc(peerId);
|
||||
if (!rpc || !rpc.opened) return null;
|
||||
try {
|
||||
return await channelRpc.rpcRequest(rpc, METHODS.CORE_STATUS, {}, timeoutMs);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inviteAck(peerId, payload = {}) {
|
||||
return channelRpc.rpcEvent(getRequestRpc(peerId), METHODS.INVITE_ACK, payload);
|
||||
}
|
||||
@@ -121,7 +135,8 @@ function registerCoreRpcHandlers(handlers) {
|
||||
[METHODS.INVITE_RELAY_RESPONSE]: handlers.onInviteRelayResponse || (async () => null),
|
||||
[METHODS.CONSENSUS_REMOVE_DOMAIN]: handlers.onConsensusRemoveDomain || (async () => null),
|
||||
[METHODS.CONSENSUS_RECALCULATE]: handlers.onConsensusRecalculate || (async () => null),
|
||||
[METHODS.CONSENSUS_REMOVE_CONFLICT_CLAIM]: handlers.onConsensusRemoveConflictClaim || (async () => null)
|
||||
[METHODS.CONSENSUS_REMOVE_CONFLICT_CLAIM]: handlers.onConsensusRemoveConflictClaim || (async () => null),
|
||||
[METHODS.CORE_STATUS]: handlers.onCoreStatus || (async () => ({ error: 'no_handler' }))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,6 +149,7 @@ module.exports = {
|
||||
registerCoreRpcHandlers,
|
||||
getRequestRpc,
|
||||
inviteRequest,
|
||||
coreStatusRequest,
|
||||
inviteAck,
|
||||
inviteUnavailable,
|
||||
inviteQueued,
|
||||
|
||||
@@ -387,6 +387,33 @@ function createCoreSwarmHandlers(ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function onCoreStatus() {
|
||||
const pass = getDnsPass();
|
||||
const dnsPassInitialized = !!pass;
|
||||
const allowAnyWriterInvites = process.env.ALLOW_ANY_WRITER_INVITES === 'true';
|
||||
let canProvideInvite = false;
|
||||
if (dnsPassInitialized) {
|
||||
if (isMaster) {
|
||||
canProvideInvite = true;
|
||||
} else if (allowAnyWriterInvites) {
|
||||
canProvideInvite = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeType: isMaster ? 'master' : 'joiner',
|
||||
dnsPassInitialized,
|
||||
canProvideInvite,
|
||||
allowAnyWriterInvites,
|
||||
isProcessingInvite: isProcessingInvite(),
|
||||
consecutiveInviteFailures: state.consecutiveInviteFailures || 0,
|
||||
connectedPeers: connectedPeers.size,
|
||||
masterQueueSize: state.pendingInviteRequests?.size || 0,
|
||||
inFlightInviteHandlers: pendingInviteRequestHandlers.size,
|
||||
failedInvitePeerCount: failedInvitePeers.size
|
||||
};
|
||||
}
|
||||
|
||||
async function onConsensusRemoveDomain(value) {
|
||||
const domain = value?.domain;
|
||||
if (!domain) return null;
|
||||
@@ -455,6 +482,7 @@ function createCoreSwarmHandlers(ctx) {
|
||||
onInviteQueued,
|
||||
onInviteRelayRequest,
|
||||
onInviteRelayResponse,
|
||||
onCoreStatus,
|
||||
onConsensusRemoveDomain,
|
||||
onConsensusRecalculate,
|
||||
onConsensusRemoveConflictClaim,
|
||||
|
||||
@@ -291,6 +291,9 @@ async function main() {
|
||||
|
||||
return diagnoseInviteIssues();
|
||||
};
|
||||
state.diagnoseInviteIssuesAsync = async function() {
|
||||
return state.diagnoseInviteIssues();
|
||||
};
|
||||
} catch (err) {
|
||||
logWarn('Main', `Error initializing plugins: ${err.message}`);
|
||||
}
|
||||
@@ -855,11 +858,70 @@ async function main() {
|
||||
onInviteQueued: swarmHandlers.onInviteQueued,
|
||||
onInviteRelayRequest: swarmHandlers.onInviteRelayRequest,
|
||||
onInviteRelayResponse: swarmHandlers.onInviteRelayResponse,
|
||||
onCoreStatus: swarmHandlers.onCoreStatus,
|
||||
onConsensusRemoveDomain: swarmHandlers.onConsensusRemoveDomain,
|
||||
onConsensusRecalculate: swarmHandlers.onConsensusRecalculate,
|
||||
onConsensusRemoveConflictClaim: swarmHandlers.onConsensusRemoveConflictClaim
|
||||
});
|
||||
|
||||
async function diagnoseInviteIssuesAsync() {
|
||||
const diagnostics = diagnoseInviteIssues();
|
||||
const peerIds = Object.keys(diagnostics.peers || {});
|
||||
if (peerIds.length === 0) {
|
||||
diagnostics.remoteQueried = 0;
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
const timeoutMs = parseInt(process.env.CORE_STATUS_RPC_TIMEOUT_MS || '3000', 10);
|
||||
let queried = 0;
|
||||
let clearedFailed = 0;
|
||||
|
||||
await Promise.all(peerIds.map(async (peerId) => {
|
||||
const local = diagnostics.peers[peerId];
|
||||
if (!local?.rpc?.ready) {
|
||||
local.remote = { ok: false, error: 'rpc_not_ready' };
|
||||
return;
|
||||
}
|
||||
queried++;
|
||||
const remote = await coreRpc.coreStatusRequest(peerId, timeoutMs);
|
||||
if (!remote || remote.error) {
|
||||
local.remote = { ok: false, error: remote?.error || 'no_response' };
|
||||
return;
|
||||
}
|
||||
local.remote = { ok: true, ...remote };
|
||||
|
||||
if (local.failedInvite && remote.canProvideInvite) {
|
||||
failedInvitePeers.delete(peerId);
|
||||
local.failedInvite = false;
|
||||
clearedFailed++;
|
||||
} else if (!local.failedInvite && remote.dnsPassInitialized && !remote.canProvideInvite && !isMaster) {
|
||||
const isJoinerOnlyPeer = remote.nodeType === 'joiner' && !remote.allowAnyWriterInvites;
|
||||
if (isJoinerOnlyPeer) {
|
||||
failedInvitePeers.add(peerId);
|
||||
local.failedInvite = true;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
diagnostics.remoteQueried = queried;
|
||||
if (clearedFailed > 0) {
|
||||
diagnostics.failedInvitePeers = Array.from(failedInvitePeers);
|
||||
diagnostics.summary.failedPeers = failedInvitePeers.size;
|
||||
diagnostics.recommendations = diagnostics.recommendations.filter(
|
||||
(r) => !r.includes('marked unavailable for invites')
|
||||
);
|
||||
if (failedInvitePeers.size > 0) {
|
||||
diagnostics.recommendations.push(
|
||||
`${failedInvitePeers.size} peer(s) marked unavailable for invites (invite.unavailable or failed invite.request)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
state.diagnoseInviteIssuesAsync = diagnoseInviteIssuesAsync;
|
||||
|
||||
channelManager.registerPluginChannel(CORE_DOMAIN, 'request', {
|
||||
encoding: 'string',
|
||||
autoReconnect: true,
|
||||
|
||||
@@ -821,6 +821,7 @@ async function setupP2NS() {
|
||||
onInviteQueued: proxySwarmHandlers.onInviteQueued,
|
||||
onInviteRelayRequest: proxySwarmHandlers.onInviteRelayRequest,
|
||||
onInviteRelayResponse: proxySwarmHandlers.onInviteRelayResponse,
|
||||
onCoreStatus: proxySwarmHandlers.onCoreStatus,
|
||||
onConsensusRemoveDomain: proxySwarmHandlers.onConsensusRemoveDomain,
|
||||
onConsensusRecalculate: proxySwarmHandlers.onConsensusRecalculate,
|
||||
onConsensusRemoveConflictClaim: proxySwarmHandlers.onConsensusRemoveConflictClaim
|
||||
|
||||
@@ -92,11 +92,56 @@ function testNoLegacyAdapterInP2ns() {
|
||||
|
||||
function testMethodsIncludeDeliver() {
|
||||
assert.strictEqual(METHODS.INVITE_DELIVER, 'invite.deliver');
|
||||
assert.strictEqual(METHODS.CORE_STATUS, 'core.status');
|
||||
assert.ok(INVITE_STATUS.OK);
|
||||
}
|
||||
|
||||
function testCoreStatusMaster() {
|
||||
const handlers = createCoreSwarmHandlers({
|
||||
Autopass: {},
|
||||
store: {},
|
||||
core: { writable: true, update: async () => {} },
|
||||
state: { consecutiveInviteFailures: 0, pendingInviteRequests: new Map() },
|
||||
isMaster: true,
|
||||
getDnsPass: () => ({}),
|
||||
setDnsPass: () => {},
|
||||
connectedPeers: new Set(['peer1', 'peer2']),
|
||||
peerChannels: new Map(),
|
||||
failedInvitePeers: new Set(),
|
||||
pendingInviteAcks: new Map(),
|
||||
pendingInviteRequestHandlers: new Set(),
|
||||
isProcessingInvite: () => false,
|
||||
acquireInviteLock: async () => {},
|
||||
releaseInviteLock: () => {},
|
||||
withTimeout: (p) => p,
|
||||
getCurrentInvitePromise: () => null,
|
||||
setCurrentInvitePromise: () => {},
|
||||
getCurrentPairOperation: () => null,
|
||||
setCurrentPairOperation: () => {},
|
||||
setupDomainsWatcher: () => {},
|
||||
listDomains: async () => [],
|
||||
doAutoVotes: () => {},
|
||||
setupListeners: () => {},
|
||||
coreRpc: {},
|
||||
channelManager: { getChannelInfo: () => null, waitForBidirectionalOpen: async () => true },
|
||||
CORE_DOMAIN: 'p2ns.core'
|
||||
});
|
||||
|
||||
return handlers.onCoreStatus().then((status) => {
|
||||
assert.strictEqual(status.nodeType, 'master');
|
||||
assert.strictEqual(status.dnsPassInitialized, true);
|
||||
assert.strictEqual(status.canProvideInvite, true);
|
||||
assert.strictEqual(status.connectedPeers, 2);
|
||||
});
|
||||
}
|
||||
|
||||
testInviteAckClearsPending();
|
||||
testInviteAckIgnoresStaleId();
|
||||
testNoLegacyAdapterInP2ns();
|
||||
testMethodsIncludeDeliver();
|
||||
console.log('core-swarm-handlers.test: ok');
|
||||
testCoreStatusMaster().then(() => {
|
||||
console.log('core-swarm-handlers.test: ok');
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user