feat(opsserver): add real-time log push to ops dashboard and recent DNS query tracking

This commit is contained in:
2026-02-21 14:02:48 +00:00
parent 1337a4905a
commit ffc93eb9d3
16 changed files with 203 additions and 22 deletions

View File

@@ -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

View File

@@ -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",

10
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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.'
}

View File

@@ -1266,6 +1266,7 @@ export class DcRouter {
question.name,
false,
event.responseTimeMs,
event.answered,
);
}
});

View File

@@ -14,8 +14,8 @@ const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
// 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',

View File

@@ -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,10 +395,22 @@ 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++;
} else {

View File

@@ -1,7 +1,7 @@
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();
@@ -10,6 +10,7 @@ export class LogsHandler {
// 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<interfaces.requests.IReq_PushLogEntry>(
'pushLogEntry',
conn,
);
push.fire({ entry }).catch(() => {}); // fire-and-forget
} catch {
// connection may have closed
}
}
},
});
}
private setupLogStream(
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
levelFilter?: string[],

View File

@@ -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,6 +437,7 @@ export class StatsHandler {
cacheHitRate: dnsStats.cacheHitRate,
topDomains: dnsStats.topDomains,
queryTypes: dnsStats.queryTypes,
recentQueries: dnsStats.recentQueries,
};
}

View File

@@ -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 {

View File

@@ -42,3 +42,15 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
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: {};
}

View File

@@ -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.'
}

View File

@@ -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<interfaces.requests.IReq_PushLogEntry>(
'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();
}
})();

View File

@@ -29,6 +29,8 @@ export class OpsViewLogs extends DeesElement {
@state()
accessor filterLimit: number = 100;
private lastPushedCount = 0;
constructor() {
super();
const subscription = appstate.logStatePart
@@ -110,8 +112,12 @@ export class OpsViewLogs extends DeesElement {
async connectedCallback() {
super.connectedCallback();
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<string, any>) {
super.updated(changedProperties);
@@ -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;
}
}

View File

@@ -133,8 +133,8 @@ export class OpsViewOverview extends DeesElement {
.logEntries=${this.getRecentEventEntries()}
></dees-chart-log>
<dees-chart-log
.label=${'Security Alerts'}
.logEntries=${this.getSecurityAlertEntries()}
.label=${'DNS Queries'}
.logEntries=${this.getDnsQueryEntries()}
></dees-chart-log>
</div>
`}
@@ -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 [];

View File

@@ -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