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:
Raven Scott
2025-12-26 18:16:02 -05:00
parent eff22719ff
commit 0e03ff10e2
5 changed files with 330 additions and 12 deletions
+165 -4
View File
@@ -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
};
+13 -3
View File
@@ -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);
+74 -1
View File
@@ -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') {
+15 -3
View File
@@ -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);
+63 -1
View File
@@ -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') {