diff --git a/changelog.md b/changelog.md index 8674abf..b5f4039 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2026-02-21 - 7.4.0 - feat(opsserver) +add real-time log push to ops dashboard and recent DNS query tracking + +- Export baseLogger and add a log destination that pushes log entries to connected ops_dashboard TypedSocket clients (ts/opsserver/handlers/logs.handler.ts, ts/logger.ts). +- Introduce a new TypedRequest (pushLogEntry) interface for server→client log pushes (ts_interfaces/requests/logs.ts) and wire client handling in the web UI (ts_web/appstate.ts, ts_web/plugins.ts). +- Add TypedSocket client connection lifecycle to the web app, stream pushed log entries into app state and update log views incrementally (ts_web/appstate.ts, ts_web/elements/ops-view-logs.ts). +- MetricsManager now records recent DNS queries (timestamp, domain, type, answered, responseTimeMs) and exposes them via stats endpoints for display in the UI (ts/monitoring/classes.metricsmanager.ts, ts/opsserver/handlers/stats.handler.ts, ts_interfaces/data/stats.ts). +- UI overview now displays DNS query entries and uses answered flag to set log level (ts_web/elements/ops-view-overview.ts). +- Add import/export for typedsocket in web plugins to enable real-time push (ts_web/plugins.ts). +- Bump dependency @push.rocks/smartproxy patch version ^25.7.8 → ^25.7.9 (package.json). + ## 2026-02-20 - 7.3.0 - feat(dcrouter) Wire DNS server 'query' events to MetricsManager for time-series tracking and bump @push.rocks/smartdns to ^7.9.0 diff --git a/package.json b/package.json index 4c262f4..a7c86c6 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", - "@push.rocks/smartproxy": "^25.7.8", + "@push.rocks/smartproxy": "^25.7.9", "@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrx": "^3.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 447f09d..f9f0498 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,8 +75,8 @@ importers: specifier: ^4.2.3 version: 4.2.3 '@push.rocks/smartproxy': - specifier: ^25.7.8 - version: 25.7.8 + specifier: ^25.7.9 + version: 25.7.9 '@push.rocks/smartradius': specifier: ^1.1.1 version: 1.1.1 @@ -1030,8 +1030,8 @@ packages: '@push.rocks/smartpromise@4.2.3': resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartproxy@25.7.8': - resolution: {integrity: sha512-rKuC/5DgCBQmk1iCY2mZd+ZdH2mBOfcP1hWMARTP4Je4KqnNTJ2STM1tJmc9FmKVXxtEQCxWJnEnq1wNqwQFRA==} + '@push.rocks/smartproxy@25.7.9': + resolution: {integrity: sha512-5esFvD72TEyveaEQbDYRgD7C5hDfWMSBvurNx3KPi02CBKG1gnhx/WWT7RHDS3KRF5fEQh9YxvI9aMkOwjc7sQ==} '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} @@ -6317,7 +6317,7 @@ snapshots: '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartproxy@25.7.8': + '@push.rocks/smartproxy@25.7.9': dependencies: '@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartlog': 3.2.1 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e0b2f90..3e096c1 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '7.3.0', + version: '7.4.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 2db8441..0c87c4e 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -1266,6 +1266,7 @@ export class DcRouter { question.name, false, event.responseTimeMs, + event.answered, ); } }); diff --git a/ts/logger.ts b/ts/logger.ts index 7eb23c1..5119bc4 100644 --- a/ts/logger.ts +++ b/ts/logger.ts @@ -14,8 +14,8 @@ const envMap: Record = { // In-memory log buffer for the OpsServer UI export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 }); -// Default Smartlog instance -const baseLogger = new plugins.smartlog.Smartlog({ +// Default Smartlog instance (exported so OpsServer can add push destinations) +export const baseLogger = new plugins.smartlog.Smartlog({ logContext: { environment: envMap[nodeEnv] || 'production', runtime: 'node', diff --git a/ts/monitoring/classes.metricsmanager.ts b/ts/monitoring/classes.metricsmanager.ts index 165f255..08bf951 100644 --- a/ts/monitoring/classes.metricsmanager.ts +++ b/ts/monitoring/classes.metricsmanager.ts @@ -36,6 +36,7 @@ export class MetricsManager { lastResetDate: new Date().toDateString(), queryTimestamps: [] as number[], // Track query timestamps for rate calculation responseTimes: [] as number[], // Track response times in ms + recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>, }; // Per-minute time-series buckets for charts @@ -95,6 +96,7 @@ export class MetricsManager { this.dnsMetrics.topDomains.clear(); this.dnsMetrics.queryTimestamps = []; this.dnsMetrics.responseTimes = []; + this.dnsMetrics.recentQueries = []; this.dnsMetrics.lastResetDate = currentDate; } @@ -228,6 +230,7 @@ export class MetricsManager { queryTypes: this.dnsMetrics.queryTypes, averageResponseTime: Math.round(avgResponseTime), activeDomains: this.dnsMetrics.topDomains.size, + recentQueries: this.dnsMetrics.recentQueries.slice(), }; }); } @@ -392,9 +395,21 @@ export class MetricsManager { } // DNS event tracking methods - public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void { + public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void { this.dnsMetrics.totalQueries++; this.incrementDnsBucket(); + + // Store recent query entry + this.dnsMetrics.recentQueries.push({ + timestamp: Date.now(), + domain, + type: queryType, + answered: answered ?? true, + responseTimeMs: responseTimeMs ?? 0, + }); + if (this.dnsMetrics.recentQueries.length > 100) { + this.dnsMetrics.recentQueries.shift(); + } if (cacheHit) { this.dnsMetrics.cacheHits++; diff --git a/ts/opsserver/handlers/logs.handler.ts b/ts/opsserver/handlers/logs.handler.ts index 0b9198e..43e6af8 100644 --- a/ts/opsserver/handlers/logs.handler.ts +++ b/ts/opsserver/handlers/logs.handler.ts @@ -1,15 +1,16 @@ import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; -import { logBuffer } from '../../logger.js'; +import { logBuffer, baseLogger } from '../../logger.js'; export class LogsHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); - + constructor(private opsServerRef: OpsServer) { // Add this handler's router to the parent this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); + this.setupLogPushDestination(); } private registerHandlers(): void { @@ -165,6 +166,50 @@ export class LogsHandler { return mapped; } + /** + * Add a log destination to the base logger that pushes entries + * to all connected ops_dashboard TypedSocket clients. + */ + private setupLogPushDestination(): void { + const opsServerRef = this.opsServerRef; + + baseLogger.addLogDestination({ + async handleLog(logPackage: any) { + // Access the TypedSocket server instance from OpsServer + const typedsocket = opsServerRef.server?.typedserver?.typedsocket; + if (!typedsocket) return; + + let connections: any[]; + try { + connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard'); + } catch { + return; + } + if (connections.length === 0) return; + + const entry: interfaces.data.ILogEntry = { + timestamp: logPackage.timestamp || Date.now(), + level: LogsHandler.mapLogLevel(logPackage.level), + category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message), + message: logPackage.message, + metadata: logPackage.data, + }; + + for (const conn of connections) { + try { + const push = typedsocket.createTypedRequest( + 'pushLogEntry', + conn, + ); + push.fire({ entry }).catch(() => {}); // fire-and-forget + } catch { + // connection may have closed + } + } + }, + }); + } + private setupLogStream( virtualStream: plugins.typedrequest.VirtualStream, levelFilter?: string[], diff --git a/ts/opsserver/handlers/stats.handler.ts b/ts/opsserver/handlers/stats.handler.ts index b2849cb..e9d650a 100644 --- a/ts/opsserver/handlers/stats.handler.ts +++ b/ts/opsserver/handlers/stats.handler.ts @@ -241,6 +241,7 @@ export class StatsHandler { averageResponseTime: 0, queryTypes: stats.queryTypes, timeSeries, + recentQueries: stats.recentQueries, }; }) ); @@ -422,6 +423,7 @@ export class StatsHandler { count: number; }>; queryTypes: { [key: string]: number }; + recentQueries?: Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>; domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats }; }> { // Get metrics from MetricsManager if available @@ -435,9 +437,10 @@ export class StatsHandler { cacheHitRate: dnsStats.cacheHitRate, topDomains: dnsStats.topDomains, queryTypes: dnsStats.queryTypes, + recentQueries: dnsStats.recentQueries, }; } - + // Fallback if MetricsManager not available return { queriesPerSecond: 0, diff --git a/ts_interfaces/data/stats.ts b/ts_interfaces/data/stats.ts index 79d6c1a..5e1cfd2 100644 --- a/ts_interfaces/data/stats.ts +++ b/ts_interfaces/data/stats.ts @@ -60,6 +60,13 @@ export interface IDnsStats { timeSeries?: { queries: ITimeSeriesPoint[]; }; + recentQueries?: Array<{ + timestamp: number; + domain: string; + type: string; + answered: boolean; + responseTimeMs: number; + }>; } export interface IRateLimitInfo { diff --git a/ts_interfaces/requests/logs.ts b/ts_interfaces/requests/logs.ts index 911efc8..757d70c 100644 --- a/ts_interfaces/requests/logs.ts +++ b/ts_interfaces/requests/logs.ts @@ -41,4 +41,16 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem response: { logStream: plugins.typedrequestInterfaces.IVirtualStream; }; +} + +// Push Log Entry (server → client via TypedSocket) +export interface IReq_PushLogEntry extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_PushLogEntry +> { + method: 'pushLogEntry'; + request: { + entry: statsInterfaces.ILogEntry; + }; + response: {}; } \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index e0b2f90..3e096c1 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '7.3.0', + version: '7.4.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 60ff2e2..b839eab 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -1075,6 +1075,55 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{ } }); +// ============================================================================ +// TypedSocket Client for Real-time Log Streaming +// ============================================================================ + +let socketClient: plugins.typedsocket.TypedSocket | null = null; +const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter(); + +// Register handler for pushed log entries from the server +socketRouter.addTypedHandler( + new plugins.domtools.plugins.typedrequest.TypedHandler( + 'pushLogEntry', + async (dataArg) => { + const current = logStatePart.getState(); + const updated = [...current.recentLogs, dataArg.entry]; + // Cap at 2000 entries + if (updated.length > 2000) { + updated.splice(0, updated.length - 2000); + } + logStatePart.setState({ ...current, recentLogs: updated }); + return {}; + } + ) +); + +async function connectSocket() { + if (socketClient) return; + try { + socketClient = await plugins.typedsocket.TypedSocket.createClient( + socketRouter, + plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(), + ); + await socketClient.setTag('role', 'ops_dashboard'); + } catch (err) { + console.error('TypedSocket connection failed:', err); + socketClient = null; + } +} + +async function disconnectSocket() { + if (socketClient) { + try { + await socketClient.stop(); + } catch { + // ignore disconnect errors + } + socketClient = null; + } +} + // Combined refresh action for efficient polling async function dispatchCombinedRefreshAction() { const context = getActionContext(); @@ -1237,9 +1286,21 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar if (state.isLoggedIn !== previousIsLoggedIn) { previousIsLoggedIn = state.isLoggedIn; startAutoRefresh(); + + // Connect/disconnect TypedSocket based on login state + if (state.isLoggedIn) { + connectSocket(); + } else { + disconnectSocket(); + } } }); // Initial start startAutoRefresh(); + + // Connect TypedSocket if already logged in (e.g., persistent session) + if (loginStatePart.getState().isLoggedIn) { + connectSocket(); + } })(); \ No newline at end of file diff --git a/ts_web/elements/ops-view-logs.ts b/ts_web/elements/ops-view-logs.ts index 6fd8113..347cd37 100644 --- a/ts_web/elements/ops-view-logs.ts +++ b/ts_web/elements/ops-view-logs.ts @@ -29,6 +29,8 @@ export class OpsViewLogs extends DeesElement { @state() accessor filterLimit: number = 100; + private lastPushedCount = 0; + constructor() { super(); const subscription = appstate.logStatePart @@ -110,7 +112,11 @@ export class OpsViewLogs extends DeesElement { async connectedCallback() { super.connectedCallback(); - this.fetchLogs(); + this.lastPushedCount = 0; + // Only fetch if state is empty (streaming will handle new entries) + if (this.logState.recentLogs.length === 0) { + this.fetchLogs(); + } } async updated(changedProperties: Map) { @@ -127,10 +133,16 @@ export class OpsViewLogs extends DeesElement { // Ensure the chart element has finished its own initialization await chartLog.updateComplete; - chartLog.clearLogs(); - const entries = this.getMappedLogEntries(); - if (entries.length > 0) { - chartLog.updateLog(entries); + const allEntries = this.getMappedLogEntries(); + if (this.lastPushedCount === 0 && allEntries.length > 0) { + // Initial load: push all entries + chartLog.updateLog(allEntries); + this.lastPushedCount = allEntries.length; + } else if (allEntries.length > this.lastPushedCount) { + // Incremental: only push new entries + const newEntries = allEntries.slice(this.lastPushedCount); + chartLog.updateLog(newEntries); + this.lastPushedCount = allEntries.length; } } diff --git a/ts_web/elements/ops-view-overview.ts b/ts_web/elements/ops-view-overview.ts index 8167d20..07cd96b 100644 --- a/ts_web/elements/ops-view-overview.ts +++ b/ts_web/elements/ops-view-overview.ts @@ -133,8 +133,8 @@ export class OpsViewOverview extends DeesElement { .logEntries=${this.getRecentEventEntries()} > `} @@ -395,6 +395,16 @@ export class OpsViewOverview extends DeesElement { })); } + private getDnsQueryEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> { + const queries: any[] = (this.statsState.dnsStats as any)?.recentQueries || []; + return queries.map((q: any) => ({ + timestamp: new Date(q.timestamp).toISOString(), + level: q.answered ? 'info' as const : 'warn' as const, + message: `${q.type} ${q.domain} (${q.responseTimeMs}ms)`, + source: 'dns', + })); + } + private getEmailTrafficSeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> { const ts = this.statsState.emailStats?.timeSeries; if (!ts) return []; diff --git a/ts_web/plugins.ts b/ts_web/plugins.ts index 364a531..678e04f 100644 --- a/ts_web/plugins.ts +++ b/ts_web/plugins.ts @@ -2,9 +2,13 @@ import * as deesElement from '@design.estate/dees-element'; import * as deesCatalog from '@design.estate/dees-catalog'; +// TypedSocket for real-time push communication +import * as typedsocket from '@api.global/typedsocket'; + export { deesElement, - deesCatalog + deesCatalog, + typedsocket, } // domtools gives us TypedRequest and other utilities