feat: implement fully dynamic real-time domains list via WebSocket
Add WebSocket broadcasting of complete domains list from server to clients Implement periodic domain list updates (10-second intervals) Fix reconnection issues where domains list remained empty after p2ns restart Add proper state management for WebSocket disconnect/reconnect scenarios Enhance empty state messages to show "Disconnected" status during outages Reset scroll/pagination state when receiving WebSocket domain updates Add client-server request/response mechanism for immediate domain list refresh Ensure domains list updates in real-time during consensus resolution Remove conflicting HTTP fetches during reconnection to prevent race conditions
This commit is contained in:
@@ -2,21 +2,126 @@ const WebSocket = require('ws');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
||||
const state = require('../../infrastructure/state');
|
||||
const { metrics } = require('../../maintenance/metrics');
|
||||
const { getAllEntries, getHashForDomain, getConsensusState } = require('../../core/core');
|
||||
const { getPersistentPublicKey } = require('../../infrastructure/utils');
|
||||
|
||||
const adminWss = new WebSocket.Server({ noServer: true });
|
||||
const adminClients = new Set();
|
||||
let healthBroadcastInterval = null;
|
||||
let domainsBroadcastInterval = null;
|
||||
|
||||
// Fetch the complete resolved domains list
|
||||
async function getResolvedDomainsList() {
|
||||
try {
|
||||
// Use fresh data (bypass cache) to ensure latest consensus state
|
||||
const allEntries = await getAllEntries(state.dnsPass, false);
|
||||
const domainClaimants = new Map();
|
||||
for (const entry of allEntries) {
|
||||
if (entry.key.startsWith('claim:')) {
|
||||
const parts = entry.key.split(':');
|
||||
if (parts.length === 3) {
|
||||
const domain = parts[1];
|
||||
const claimant = parts[2];
|
||||
if (!domainClaimants.has(domain)) domainClaimants.set(domain, new Set());
|
||||
domainClaimants.get(domain).add(claimant);
|
||||
}
|
||||
}
|
||||
}
|
||||
const localWriter = getPersistentPublicKey();
|
||||
const domains = new Set(domainClaimants.keys());
|
||||
const resolved = [];
|
||||
for (const domain of domains) {
|
||||
const hash = await getHashForDomain(domain) || 'none';
|
||||
const isLocal = localWriter ? domainClaimants.get(domain)?.has(localWriter) || false : false;
|
||||
|
||||
let isOwner = false;
|
||||
let consensusState = null;
|
||||
try {
|
||||
consensusState = await getConsensusState(domain);
|
||||
isOwner = localWriter ? consensusState.resolvedClaimant === localWriter : false;
|
||||
} catch (err) {
|
||||
logDebug('Admin', `Error checking ownership/consensus for ${domain}: ${err.message}`);
|
||||
}
|
||||
|
||||
// Determine consensus status, including conflict detection
|
||||
let consensusStatus = null;
|
||||
if (consensusState) {
|
||||
if (isLocal && !isOwner && consensusState.status === 'resolved') {
|
||||
// Conflict: user has local claim but another claimant won
|
||||
consensusStatus = 'conflict';
|
||||
} else {
|
||||
consensusStatus = consensusState.status;
|
||||
}
|
||||
}
|
||||
|
||||
resolved.push({ domain, hash, isLocal, isOwner, consensusState, consensusStatus });
|
||||
}
|
||||
let internalDomains = ['p2ns.admin'];
|
||||
try {
|
||||
const { getInternalDomains } = require('../../plugins/plugin-handler');
|
||||
internalDomains = await getInternalDomains();
|
||||
} catch (err) {
|
||||
// Fallback if plugin system not available
|
||||
}
|
||||
// Only add internal domains that aren't already in the resolved list
|
||||
for (const d of internalDomains) {
|
||||
if (!resolved.some(r => r.domain === d)) {
|
||||
resolved.push({ domain: d, hash: 'internal', isLocal: true, isOwner: true, consensusStatus: 'internal' });
|
||||
}
|
||||
}
|
||||
return resolved.sort((a, b) => a.domain.localeCompare(b.domain));
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to fetch resolved domains list: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
adminWss.on('connection', (ws) => {
|
||||
logDebug('Admin', 'WebSocket client connected');
|
||||
adminClients.add(ws);
|
||||
|
||||
|
||||
// Send initial domain list
|
||||
getResolvedDomainsList().then(domains => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'domains-list',
|
||||
domains: domains,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
}).catch(err => {
|
||||
logError('Admin', `Error sending initial domains list: ${err.message}`);
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
if (data.type === 'request-domains') {
|
||||
// Client requested domains list
|
||||
getResolvedDomainsList().then(domains => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'domains-list',
|
||||
domains: domains,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
}).catch(err => {
|
||||
logError('Admin', `Error sending requested domains list: ${err.message}`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Admin', `Error handling WebSocket message: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle close event
|
||||
ws.on('close', () => {
|
||||
adminClients.delete(ws);
|
||||
logDebug('Admin', 'WebSocket client disconnected');
|
||||
});
|
||||
|
||||
|
||||
// Handle error event to ensure cleanup
|
||||
ws.on('error', (err) => {
|
||||
logError('Admin', `WebSocket error: ${err.message}`);
|
||||
@@ -29,7 +134,7 @@ adminWss.on('connection', (ws) => {
|
||||
logDebug('Admin', `Error closing WebSocket after error: ${closeErr.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Handle unexpected termination
|
||||
ws.on('unexpected-response', () => {
|
||||
logWarn('Admin', 'WebSocket received unexpected response');
|
||||
@@ -43,6 +148,13 @@ function broadcast(msg) {
|
||||
client.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// If this is an update-database message, also broadcast the domains list
|
||||
if (msg.type === 'update-database') {
|
||||
setTimeout(() => {
|
||||
broadcastDomainsList();
|
||||
}, 100); // Small delay to ensure the update-database message is processed first
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllWebSockets() {
|
||||
@@ -60,6 +172,9 @@ function closeAllWebSockets() {
|
||||
|
||||
// Stop health broadcasts
|
||||
stopHealthBroadcasts();
|
||||
|
||||
// Stop domains broadcasts
|
||||
stopDomainsBroadcasts();
|
||||
|
||||
// Close the WebSocket server
|
||||
try {
|
||||
@@ -124,15 +239,61 @@ function broadcastHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start periodic domains broadcasts
|
||||
function startDomainsBroadcasts() {
|
||||
if (domainsBroadcastInterval) return;
|
||||
|
||||
// Broadcast immediately
|
||||
broadcastDomainsList();
|
||||
|
||||
// Then every 10 seconds
|
||||
domainsBroadcastInterval = setInterval(() => {
|
||||
broadcastDomainsList();
|
||||
}, 10000);
|
||||
|
||||
logDebug('Admin', 'Domains broadcasts started');
|
||||
}
|
||||
|
||||
// Stop domains broadcasts
|
||||
function stopDomainsBroadcasts() {
|
||||
if (domainsBroadcastInterval) {
|
||||
clearInterval(domainsBroadcastInterval);
|
||||
domainsBroadcastInterval = null;
|
||||
logDebug('Admin', 'Domains broadcasts stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast domains list to all connected clients
|
||||
function broadcastDomainsList() {
|
||||
if (adminClients.size === 0) return;
|
||||
|
||||
getResolvedDomainsList().then(domains => {
|
||||
broadcast({
|
||||
type: 'domains-list',
|
||||
domains: domains,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}).catch(err => {
|
||||
logError('Admin', `Error broadcasting domains list: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Start health broadcasts when module loads
|
||||
startHealthBroadcasts();
|
||||
|
||||
// Start domains broadcasts when module loads
|
||||
startDomainsBroadcasts();
|
||||
|
||||
module.exports = {
|
||||
adminWss,
|
||||
adminClients,
|
||||
broadcast,
|
||||
closeAllWebSockets,
|
||||
startHealthBroadcasts,
|
||||
stopHealthBroadcasts
|
||||
stopHealthBroadcasts,
|
||||
startDomainsBroadcasts,
|
||||
stopDomainsBroadcasts,
|
||||
broadcastDomainsList,
|
||||
getResolvedDomainsList
|
||||
};
|
||||
|
||||
|
||||
@@ -136,11 +136,21 @@ function genericRenderInfiniteScroll(tabId) {
|
||||
const emptyRow = document.createElement('tr');
|
||||
emptyRow.className = 'border-b';
|
||||
const colCount = tabId === 'host-servers' ? 7 : (tabId === 'host-clients' ? 6 : (tabId === 'domains' ? 4 : 3));
|
||||
let message = '';
|
||||
let subMessage = '';
|
||||
if (tabId === 'domains' && !window.wsConnected) {
|
||||
message = 'Disconnected';
|
||||
subMessage = 'Reconnecting to P2NS server...';
|
||||
} else {
|
||||
message = `No ${tabId === 'host-servers' ? 'servers' : tabId === 'host-clients' ? 'clients' : 'items'} found`;
|
||||
subMessage = tabId === 'host-servers' ? 'Create a server to get started' : tabId === 'host-clients' ? 'Create a client to get started' : 'Try adjusting your search';
|
||||
}
|
||||
|
||||
emptyRow.innerHTML = `<td colspan="${colCount}" class="p-8 text-center theme-text-tertiary">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-4xl">📭</span>
|
||||
<span class="text-lg font-semibold">No ${tabId === 'host-servers' ? 'servers' : tabId === 'host-clients' ? 'clients' : 'items'} found</span>
|
||||
<span class="text-sm">${tabId === 'host-servers' ? 'Create a server to get started' : tabId === 'host-clients' ? 'Create a client to get started' : 'Try adjusting your search'}</span>
|
||||
<span class="text-4xl">${tabId === 'domains' && !window.wsConnected ? '🔌' : '📭'}</span>
|
||||
<span class="text-lg font-semibold">${message}</span>
|
||||
<span class="text-sm">${subMessage}</span>
|
||||
</div>
|
||||
</td>`;
|
||||
container.appendChild(emptyRow);
|
||||
|
||||
@@ -49,14 +49,36 @@ function connectWebSocket() {
|
||||
window.wsReconnectAttempts = 0;
|
||||
stopPollingFallback();
|
||||
if (window.updateStatus) window.updateStatus();
|
||||
|
||||
// Refresh data on reconnection to ensure everything is up to date
|
||||
if (window.activeTab === 'host' && window.genericFetch) {
|
||||
window.genericFetch('host-servers', true);
|
||||
window.genericFetch('host-clients', true);
|
||||
}
|
||||
|
||||
// Start periodic domains updates as fallback
|
||||
if (window.startDomainsUpdates) {
|
||||
window.startDomainsUpdates();
|
||||
}
|
||||
|
||||
// Request domains list from server
|
||||
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
||||
window.ws.send(JSON.stringify({ type: 'request-domains' }));
|
||||
}
|
||||
};
|
||||
|
||||
window.ws.onclose = () => {
|
||||
window.wsConnected = false;
|
||||
// Stop periodic domains updates when disconnected
|
||||
if (window.stopDomainsUpdates) {
|
||||
window.stopDomainsUpdates();
|
||||
}
|
||||
|
||||
// Clear domains data and show disconnected state when WebSocket disconnects
|
||||
window.domainsData = [];
|
||||
if (window.activeTab === 'domains' && window.genericFilter) {
|
||||
window.genericFilter('domains');
|
||||
}
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
@@ -151,14 +173,44 @@ function connectWebSocket() {
|
||||
if (window.genericFetch) window.genericFetch('settings', true);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'domains-list') {
|
||||
// Update domains data directly from WebSocket
|
||||
if (data.domains) {
|
||||
window.domainsData = data.domains;
|
||||
|
||||
// Reset infinite scroll state when receiving fresh WebSocket data
|
||||
if (window.infiniteScrollState && window.infiniteScrollState['domains']) {
|
||||
window.infiniteScrollState['domains'].loadedCount = 0;
|
||||
window.infiniteScrollState['domains'].lastQuery = '';
|
||||
// Disconnect existing observer
|
||||
if (window.infiniteScrollState['domains'].observer) {
|
||||
window.infiniteScrollState['domains'].observer.disconnect();
|
||||
window.infiniteScrollState['domains'].observer = null;
|
||||
}
|
||||
// Clear container
|
||||
const container = document.getElementById('domainsTable');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Render if domains tab is active
|
||||
if (window.activeTab === 'domains' && window.genericFilter) {
|
||||
window.genericFilter('domains');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (window.updateMap && window.updateMap[data.type]) {
|
||||
if (typeof window.updateMap[data.type] === 'function') {
|
||||
window.updateMap[data.type]();
|
||||
} else {
|
||||
window.updateMap[data.type].forEach(tab => {
|
||||
// Always fetch data in background, but only render if tab is active
|
||||
// For domains tab, always render when active to ensure real-time updates
|
||||
const shouldRender = tab === 'domains' ? window.activeTab === 'domains' : (window.activeTab === 'host' || window.activeTab === tab);
|
||||
if (window.genericFetch) {
|
||||
window.genericFetch(tab, window.activeTab === 'host' || window.activeTab === tab);
|
||||
window.genericFetch(tab, shouldRender);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -291,6 +343,25 @@ function stopStatusUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
function startDomainsUpdates() {
|
||||
if (window.domainsUpdateInterval) {
|
||||
clearInterval(window.domainsUpdateInterval);
|
||||
}
|
||||
// Refresh domains data every 30 seconds as fallback for missed WebSocket updates
|
||||
window.domainsUpdateInterval = setInterval(() => {
|
||||
if (window.wsConnected && window.genericFetch) {
|
||||
window.genericFetch('domains', window.activeTab === 'domains');
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function stopDomainsUpdates() {
|
||||
if (window.domainsUpdateInterval) {
|
||||
clearInterval(window.domainsUpdateInterval);
|
||||
window.domainsUpdateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.connectWebSocket = connectWebSocket;
|
||||
window.startPollingFallback = startPollingFallback;
|
||||
@@ -299,6 +370,8 @@ window.cleanupWebSocket = cleanupWebSocket;
|
||||
window.updateStatus = updateStatus;
|
||||
window.startStatusUpdates = startStatusUpdates;
|
||||
window.stopStatusUpdates = stopStatusUpdates;
|
||||
window.startDomainsUpdates = startDomainsUpdates;
|
||||
window.stopDomainsUpdates = stopDomainsUpdates;
|
||||
|
||||
// Initialize WebSocket connection
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -52,11 +52,23 @@ function genericRenderPaginated(tabId) {
|
||||
const emptyRow = document.createElement('tr');
|
||||
emptyRow.className = 'border-b';
|
||||
const colCount = tabId === 'host-servers' ? 7 : (tabId === 'host-clients' ? 6 : (tabId === 'local-dns' ? 5 : 3));
|
||||
let message = '';
|
||||
let subMessage = '';
|
||||
let icon = '📭';
|
||||
if (tabId === 'domains' && !window.wsConnected) {
|
||||
message = 'Disconnected';
|
||||
subMessage = 'Reconnecting to P2NS server...';
|
||||
icon = '🔌';
|
||||
} else {
|
||||
message = `No ${tabId === 'host-servers' ? 'servers' : tabId === 'host-clients' ? 'clients' : 'items'} found`;
|
||||
subMessage = tabId === 'host-servers' ? 'Create a server to get started' : tabId === 'host-clients' ? 'Create a client to get started' : 'Try adjusting your search';
|
||||
}
|
||||
|
||||
emptyRow.innerHTML = `<td colspan="${colCount}" class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-4xl">📭</span>
|
||||
<span class="text-lg font-semibold">No ${tabId === 'host-servers' ? 'servers' : tabId === 'host-clients' ? 'clients' : 'items'} found</span>
|
||||
<span class="text-sm">${tabId === 'host-servers' ? 'Create a server to get started' : tabId === 'host-clients' ? 'Create a client to get started' : 'Try adjusting your search'}</span>
|
||||
<span class="text-4xl">${icon}</span>
|
||||
<span class="text-lg font-semibold">${message}</span>
|
||||
<span class="text-sm">${subMessage}</span>
|
||||
</div>
|
||||
</td>`;
|
||||
container.appendChild(emptyRow);
|
||||
|
||||
@@ -49,14 +49,36 @@ function connectWebSocket() {
|
||||
window.wsReconnectAttempts = 0;
|
||||
stopPollingFallback();
|
||||
if (window.updateStatus) window.updateStatus();
|
||||
|
||||
// Refresh data on reconnection to ensure everything is up to date
|
||||
if (window.activeTab === 'host' && window.genericFetch) {
|
||||
window.genericFetch('host-servers', true);
|
||||
window.genericFetch('host-clients', true);
|
||||
}
|
||||
|
||||
// Start periodic domains updates as fallback
|
||||
if (window.startDomainsUpdates) {
|
||||
window.startDomainsUpdates();
|
||||
}
|
||||
|
||||
// Request domains list from server
|
||||
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
||||
window.ws.send(JSON.stringify({ type: 'request-domains' }));
|
||||
}
|
||||
};
|
||||
|
||||
window.ws.onclose = () => {
|
||||
window.wsConnected = false;
|
||||
// Stop periodic domains updates when disconnected
|
||||
if (window.stopDomainsUpdates) {
|
||||
window.stopDomainsUpdates();
|
||||
}
|
||||
|
||||
// Clear domains data and show disconnected state when WebSocket disconnects
|
||||
window.domainsData = [];
|
||||
if (window.activeTab === 'domains' && window.genericFilter) {
|
||||
window.genericFilter('domains');
|
||||
}
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
@@ -143,14 +165,33 @@ function connectWebSocket() {
|
||||
if (window.genericFetch) window.genericFetch('settings', true);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'domains-list') {
|
||||
// Update domains data directly from WebSocket
|
||||
if (data.domains) {
|
||||
window.domainsData = data.domains;
|
||||
|
||||
// Reset pagination state when receiving fresh WebSocket data
|
||||
if (window.paginationState && window.paginationState['domains']) {
|
||||
window.paginationState['domains'].current = 1;
|
||||
}
|
||||
|
||||
// Render if domains tab is active
|
||||
if (window.activeTab === 'domains' && window.genericFilter) {
|
||||
window.genericFilter('domains');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (window.updateMap && window.updateMap[data.type]) {
|
||||
if (typeof window.updateMap[data.type] === 'function') {
|
||||
window.updateMap[data.type]();
|
||||
} else {
|
||||
window.updateMap[data.type].forEach(tab => {
|
||||
// Always fetch data in background, but only render if tab is active
|
||||
// For domains tab, always render when active to ensure real-time updates
|
||||
const shouldRender = tab === 'domains' ? window.activeTab === 'domains' : (window.activeTab === 'host' || window.activeTab === tab);
|
||||
if (window.genericFetch) {
|
||||
window.genericFetch(tab, window.activeTab === 'host' || window.activeTab === tab);
|
||||
window.genericFetch(tab, shouldRender);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -252,6 +293,25 @@ function stopStatusUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
function startDomainsUpdates() {
|
||||
if (window.domainsUpdateInterval) {
|
||||
clearInterval(window.domainsUpdateInterval);
|
||||
}
|
||||
// Refresh domains data every 30 seconds as fallback for missed WebSocket updates
|
||||
window.domainsUpdateInterval = setInterval(() => {
|
||||
if (window.wsConnected && window.genericFetch) {
|
||||
window.genericFetch('domains', window.activeTab === 'domains');
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function stopDomainsUpdates() {
|
||||
if (window.domainsUpdateInterval) {
|
||||
clearInterval(window.domainsUpdateInterval);
|
||||
window.domainsUpdateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.connectWebSocket = connectWebSocket;
|
||||
window.startPollingFallback = startPollingFallback;
|
||||
@@ -260,6 +320,8 @@ window.cleanupWebSocket = cleanupWebSocket;
|
||||
window.updateStatus = updateStatus;
|
||||
window.startStatusUpdates = startStatusUpdates;
|
||||
window.stopStatusUpdates = stopStatusUpdates;
|
||||
window.startDomainsUpdates = startDomainsUpdates;
|
||||
window.stopDomainsUpdates = stopDomainsUpdates;
|
||||
|
||||
// Initialize WebSocket connection
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
Reference in New Issue
Block a user