import * as plugins from '../plugins.js'; import * as interfaces from '../../ts_interfaces/index.js'; import { runBackgroundRefresh } from './shared.js'; import { getActionContext, loginStatePart, logoutAction } from './login.js'; import { uiStatePart } from './ui.js'; import { statsStatePart } from './stats.js'; import { logStatePart, type ILogState } from './logs.js'; import { networkStatePart, refreshNetworkIpIntelligence } from './network.js'; import { securityPolicyStatePart, fetchSecurityPolicyAction } from './security.js'; import { certificateStatePart, fetchCertificateOverviewAction } from './certificates.js'; import { remoteIngressStatePart, fetchRemoteIngressAction } from './remoteingress.js'; import { vpnStatePart, fetchVpnAction } from './vpn.js'; import { emailDomainsStatePart, fetchEmailDomainsAction } from './email-domains.js'; import { emailOpsStatePart, fetchAllEmailsAction, fetchEmailSecurityFindingsAction, } from './email-ops.js'; import { domainsStatePart, fetchDnsRecordsForDomainAction, fetchDomainsAndProvidersAction, } from './domains.js'; import { fetchHttpRedirectsAction, fetchMergedRoutesAction, routeManagementStatePart, } from './routes.js'; import { opsRealtimeFreshness } from './realtime.js'; // ============================================================================ // TypedSocket Client for Real-time Log Streaming // ============================================================================ let socketClient: plugins.typedsocket.TypedSocket | null = null; let socketStatusSubscription: { unsubscribe: () => void } | null = null; let socketConnectInFlight = false; let socketGeneration = 0; const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter(); type TRealtimeRefreshGroup = | 'emailDomains' | 'emailOps' | 'dns' | 'routes' | 'edges' | 'certificates' | 'configEvents'; const pendingRealtimeRefreshes = new Set(); let realtimeRefreshLoopRunning = false; const resourceRefreshGroups: Record< interfaces.requests.TOpsRealtimeResource, TRealtimeRefreshGroup > = { emailDomains: 'emailDomains', emailQueue: 'emailOps', smtpAttempts: 'emailOps', dns: 'dns', routes: 'routes', edges: 'edges', certificates: 'certificates', configEvents: 'configEvents', }; const refreshRealtimeGroup = async (groupArg: TRealtimeRefreshGroup): Promise => { switch (groupArg) { case 'emailDomains': await emailDomainsStatePart.dispatchAction(fetchEmailDomainsAction, null); break; case 'emailOps': if (uiStatePart.getState()!.activeView !== 'email') { break; } if (uiStatePart.getState()!.activeSubview === 'security') { await emailOpsStatePart.dispatchAction(fetchEmailSecurityFindingsAction, undefined); } else if (uiStatePart.getState()!.activeSubview === 'log') { await emailOpsStatePart.dispatchAction(fetchAllEmailsAction, null); } break; case 'dns': { const selectedDomainId = domainsStatePart.getState()!.selectedDomainId; await domainsStatePart.dispatchAction(fetchDomainsAndProvidersAction, null); if (selectedDomainId) { await domainsStatePart.dispatchAction( fetchDnsRecordsForDomainAction, { domainId: selectedDomainId }, ); } break; } case 'routes': await Promise.all([ routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null), routeManagementStatePart.dispatchAction(fetchHttpRedirectsAction, null), ]); break; case 'edges': await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); break; case 'certificates': await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); break; case 'configEvents': { const { configEventStatePart, fetchConfigEventsAction } = await import('./config-events.js'); await configEventStatePart.dispatchAction(fetchConfigEventsAction, null); break; } } }; const runRealtimeRefreshLoop = async (): Promise => { if (realtimeRefreshLoopRunning) return; realtimeRefreshLoopRunning = true; try { while (pendingRealtimeRefreshes.size > 0) { const groups = Array.from(pendingRealtimeRefreshes); pendingRealtimeRefreshes.clear(); await Promise.all( groups.map(async (group) => { try { await refreshRealtimeGroup(group); } catch (error) { console.error('Realtime resource refresh failed:', group, error); } }), ); } } finally { realtimeRefreshLoopRunning = false; if (pendingRealtimeRefreshes.size > 0) { queueMicrotask(() => void runRealtimeRefreshLoop()); } } }; const acceptRealtimeInvalidations = ( serverEpochArg: string, invalidationsArg: interfaces.requests.IOpsRealtimeInvalidation[], ): void => { const accepted = opsRealtimeFreshness.accept(serverEpochArg, invalidationsArg); for (const invalidation of accepted) { pendingRealtimeRefreshes.add(resourceRefreshGroups[invalidation.resource]); } if (accepted.length > 0) { queueMicrotask(() => void runRealtimeRefreshLoop()); } }; // Batched log entry handler — buffers incoming entries and flushes once per animation frame let logEntryBuffer: interfaces.data.ILogEntry[] = []; let logFlushScheduled = false; function flushLogEntries() { logFlushScheduled = false; if (logEntryBuffer.length === 0) return; const current = logStatePart.getState()!; const updated = [...current.recentLogs, ...logEntryBuffer]; logEntryBuffer = []; // Cap at 2000 entries if (updated.length > 2000) { updated.splice(0, updated.length - 2000); } logStatePart.setState({ ...current, recentLogs: updated } as ILogState); } // Register handler for pushed log entries from the server socketRouter.addTypedHandler( new plugins.domtools.plugins.typedrequest.TypedHandler( 'pushLogEntry', async (dataArg) => { logEntryBuffer.push(dataArg.entry); if (!logFlushScheduled) { logFlushScheduled = true; requestAnimationFrame(flushLogEntries); } return {}; } ) ); socketRouter.addTypedHandler( new plugins.domtools.plugins.typedrequest.TypedHandler< interfaces.requests.IReq_PushOpsRealtimeInvalidation >( 'pushOpsRealtimeInvalidation', async (dataArg) => { acceptRealtimeInvalidations(dataArg.serverEpoch, dataArg.invalidations); return {}; }, ), ); const subscribeSocket = async ( clientArg: plugins.typedsocket.TypedSocket, generationArg: number, ): Promise => { const identity = getActionContext().identity; if (!identity || generationArg !== socketGeneration || clientArg !== socketClient) return; const request = clientArg.createTypedRequest( 'subscribeOpsRealtime', ); const response = await request.fire({ identity, ...opsRealtimeFreshness.getKnownState(), }); if (generationArg !== socketGeneration || clientArg !== socketClient) return; acceptRealtimeInvalidations(response.serverEpoch, response.invalidations); }; async function connectSocket() { if (socketClient || socketConnectInFlight || document.hidden) return; socketConnectInFlight = true; const generation = ++socketGeneration; try { const client = await plugins.typedsocket.TypedSocket.createClient( socketRouter, plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(), { autoReconnect: true }, ); if (generation !== socketGeneration || !loginStatePart.getState()!.isLoggedIn) { await client.stop(); return; } socketClient = client; socketStatusSubscription = client.statusSubject.subscribe((status) => { if (status === 'connected') { void subscribeSocket(client, generation).catch((error) => { console.error('Realtime subscription failed after reconnect:', error); }); } }); await subscribeSocket(client, generation); } catch (err) { console.error('TypedSocket connection failed:', err); socketStatusSubscription?.unsubscribe(); socketStatusSubscription = null; if (socketClient) { await socketClient.stop().catch(() => undefined); } socketClient = null; } finally { socketConnectInFlight = false; } } async function disconnectSocket() { socketGeneration++; socketStatusSubscription?.unsubscribe(); socketStatusSubscription = null; const client = socketClient; socketClient = null; if (client) { try { await client.stop(); } catch { // ignore disconnect errors } } } // In-flight guard to prevent concurrent refresh requests let isRefreshing = false; // Combined refresh action for efficient polling async function dispatchCombinedRefreshAction() { if (isRefreshing) return; isRefreshing = true; try { await dispatchCombinedRefreshActionInner(); } finally { isRefreshing = false; } } async function dispatchCombinedRefreshActionInner() { const context = getActionContext(); if (!context.identity) return; const currentView = uiStatePart.getState()!.activeView; const currentSubview = uiStatePart.getState()!.activeSubview; try { // Always fetch basic stats for dashboard widgets const combinedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetCombinedMetrics >('/typedrequest', 'getCombinedMetrics'); const combinedResponse = await combinedRequest.fire({ identity: context.identity, sections: { server: true, email: true, dns: true, security: true, network: currentView === 'network' && currentSubview === 'activity', radius: true, vpn: true, }, }); // Update all stats from combined response const currentStatsState = statsStatePart.getState()!; statsStatePart.setState({ ...currentStatsState, serverStats: combinedResponse.metrics.server || currentStatsState.serverStats, emailStats: combinedResponse.metrics.email || currentStatsState.emailStats, dnsStats: combinedResponse.metrics.dns || currentStatsState.dnsStats, securityMetrics: combinedResponse.metrics.security || currentStatsState.securityMetrics, radiusStats: combinedResponse.metrics.radius || currentStatsState.radiusStats, vpnStats: combinedResponse.metrics.vpn || currentStatsState.vpnStats, lastUpdated: Date.now(), isLoading: false, error: null, }); // Update network stats if included if (combinedResponse.metrics.network && currentView === 'network') { const network = combinedResponse.metrics.network; const connectionsByIP: { [ip: string]: number } = {}; // Build connectionsByIP from connectionDetails (now populated with real per-IP data) network.connectionDetails.forEach(conn => { connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + (conn.connectionCount || 1); }); // Build connections from connectionDetails (real per-IP aggregates) const connections: interfaces.data.IConnectionInfo[] = network.connectionDetails.map((conn, i) => ({ id: `ip-${conn.remoteAddress}`, remoteAddress: conn.remoteAddress, localAddress: 'server', startTime: conn.startTime, protocol: conn.protocol as any, state: conn.state as any, bytesReceived: conn.bytesIn, bytesSent: conn.bytesOut, connectionCount: conn.connectionCount, })); networkStatePart.setState({ ...networkStatePart.getState()!, connections, connectionsByIP, throughputRate: { bytesInPerSecond: network.totalBandwidth.in, bytesOutPerSecond: network.totalBandwidth.out, }, totalBytes: network.totalBytes || { in: 0, out: 0 }, topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.connections })), topIPsByBandwidth: (network.topEndpointsByBandwidth || []).map(e => ({ ip: e.endpoint, count: e.connections, bwIn: e.bandwidth?.in || 0, bwOut: e.bandwidth?.out || 0, })), topASNs: network.topASNs || [], throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })), domainActivity: network.domainActivity || [], throughputHistory: network.throughputHistory || [], frontendConnectionHistory: network.frontendConnectionHistory || [], backendConnectionHistory: network.backendConnectionHistory || [], requestsPerSecond: network.requestsPerSecond || 0, requestsTotal: network.requestsTotal || 0, backends: network.backends || [], frontendProtocols: network.frontendProtocols || null, backendProtocols: network.backendProtocols || null, lastUpdated: Date.now(), isLoading: false, error: null, }); refreshNetworkIpIntelligence(context.identity, [ ...network.connectionDetails.map((conn) => conn.remoteAddress), ...network.topEndpoints.map((endpoint) => endpoint.endpoint), ...(network.topEndpointsByBandwidth || []).map((endpoint) => endpoint.endpoint), ]); } if (currentView === 'security') { runBackgroundRefresh('securityPolicy', 'Security policy refresh failed:', async () => { await securityPolicyStatePart.dispatchAction(fetchSecurityPolicyAction, null); }); } // Refresh certificate data if on Domains > Certificates subview if (currentView === 'domains' && currentSubview === 'certificates') { runBackgroundRefresh('certificates', 'Certificate refresh failed:', async () => { await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); }); } // Refresh remote ingress data if on the Network → Remote Ingress subview if (currentView === 'network' && currentSubview === 'remoteingress') { runBackgroundRefresh('remoteIngress', 'Remote ingress refresh failed:', async () => { await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); }); } // Refresh VPN data if on the Network → VPN subview if (currentView === 'network' && currentSubview === 'vpn') { runBackgroundRefresh('vpn', 'VPN refresh failed:', async () => { await vpnStatePart.dispatchAction(fetchVpnAction, null); }); } } catch (error) { console.error('Combined refresh failed:', error); // If the error looks like an auth failure (invalid JWT), force re-login const errMsg = String(error); if (errMsg.includes('invalid') || errMsg.includes('unauthorized') || errMsg.includes('401')) { await loginStatePart.dispatchAction(logoutAction, null); window.location.reload(); } } } // Create a proper action for the combined refresh so we can use createScheduledAction const combinedRefreshAction = statsStatePart.createAction(async (statePartArg) => { await dispatchCombinedRefreshAction(); // Return current state — dispatchCombinedRefreshAction already updates all state parts directly return statePartArg.getState()!; }); // Scheduled refresh process with autoPause: 'visibility' — automatically pauses when tab is hidden let refreshProcess: ReturnType | null = null; const startAutoRefresh = () => { const uiState = uiStatePart.getState()!; const loginState = loginStatePart.getState()!; if (uiState.autoRefresh && loginState.isLoggedIn) { // Dispose old process if interval changed or not running if (refreshProcess) { refreshProcess.dispose(); refreshProcess = null; } refreshProcess = statsStatePart.createScheduledAction({ action: combinedRefreshAction, payload: undefined, intervalMs: uiState.refreshInterval, autoPause: 'visibility', }); } else { if (refreshProcess) { refreshProcess.dispose(); refreshProcess = null; } } }; // Watch for relevant changes let previousAutoRefresh = uiStatePart.getState()!.autoRefresh; let previousRefreshInterval = uiStatePart.getState()!.refreshInterval; let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn; uiStatePart.select((s) => ({ autoRefresh: s.autoRefresh, refreshInterval: s.refreshInterval })) .subscribe((state) => { if (state.autoRefresh !== previousAutoRefresh || state.refreshInterval !== previousRefreshInterval) { previousAutoRefresh = state.autoRefresh; previousRefreshInterval = state.refreshInterval; startAutoRefresh(); } }); loginStatePart.select((s) => s.isLoggedIn).subscribe((isLoggedIn) => { if (isLoggedIn !== previousIsLoggedIn) { previousIsLoggedIn = isLoggedIn; startAutoRefresh(); // Connect/disconnect TypedSocket based on login state if (isLoggedIn) { connectSocket(); } else { pendingRealtimeRefreshes.clear(); opsRealtimeFreshness.reset(); disconnectSocket(); } } }); // Pause/resume WebSocket when tab visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden) { disconnectSocket(); } else if (loginStatePart.getState()!.isLoggedIn) { connectSocket(); } }); // Initial start startAutoRefresh(); // Connect TypedSocket if already logged in (e.g., persistent session) if (loginStatePart.getState()!.isLoggedIn) { connectSocket(); }