diff --git a/changelog.md b/changelog.md index a4e5280..2fa66fb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2026-02-19 - 7.1.0 - feat(ops/monitoring) +add in-memory log buffer, metrics time-series and ops UI integration + +- bump @push.rocks/smartlog to ^3.2.0 +- introduce SmartlogDestinationBuffer (logBuffer) and wire it into the base logger to provide an in-memory log store for the Ops UI +- implement minute-resolution time-series buckets in MetricsManager with increment/prune helpers and new APIs getEmailTimeSeries and getDnsTimeSeries +- sync security counters from SecurityLogger and expose recent security events via StatsHandler +- wire email delivery lifecycle events and bounce processing to MetricsManager for tracking sent/received/failed metrics +- LogsHandler now queries the in-memory log buffer, maps smartlog levels/categories, supports search/level/time-range filtering and pagination +- UI updates: ops-view-overview, ops-view-logs and ops-view-security consume time-series and recent events to render charts, tables and filters + ## 2026-02-19 - 7.0.1 - fix(monitoring) Use smartMetrics cpuPercentage for cpuUsage.user and update smartmetrics and smartproxy dependencies diff --git a/package.json b/package.json index 6fdff1d..6b69b7b 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartjwt": "^2.2.1", - "@push.rocks/smartlog": "^3.1.11", + "@push.rocks/smartlog": "^3.2.0", "@push.rocks/smartmetrics": "^3.0.1", "@push.rocks/smartmongo": "^5.1.0", "@push.rocks/smartmta": "^5.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17d8ba2..6e779e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: ^2.2.1 version: 2.2.1 '@push.rocks/smartlog': - specifier: ^3.1.11 - version: 3.1.11 + specifier: ^3.2.0 + version: 3.2.0 '@push.rocks/smartmetrics': specifier: ^3.0.1 version: 3.0.1 @@ -960,8 +960,8 @@ packages: '@push.rocks/smartlog-interfaces@3.0.2': resolution: {integrity: sha512-8hGRTJehbsFSJxLhCQkA018mZtXVPxPTblbg9VaE/EqISRzUw+eosJ2EJV7M4Qu0eiTJZjnWnNLn8CkD77ziWw==} - '@push.rocks/smartlog@3.1.11': - resolution: {integrity: sha512-zyLH8pQD2UD7l76wJBESEWXU1FSTBLOuRI0/DN139EYyMkwMq1+pdQKptTkJhhVL/OIj56oMg9SpJb4bJB7uKg==} + '@push.rocks/smartlog@3.2.0': + resolution: {integrity: sha512-d6IzsSG8HTmgxr8c9BVzZWpn8m3c17b5O+orHdwrlgHqPVa0+WXMe1ezItuPVZH5q6i8h+OfCv382PTloNiweg==} '@push.rocks/smartmail@2.2.0': resolution: {integrity: sha512-28K4HAcda7ODUUpFCgbS/uA+eqwVRcmLJERIdM9AvLHXaHAPLHH97HmwPPcAu9Sp3z05Um0inmDF51X6yVVkcw==} @@ -1127,9 +1127,6 @@ packages: '@push.rocks/webrequest@3.0.37': resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==} - '@push.rocks/webrequest@4.0.1': - resolution: {integrity: sha512-I60XZZLVf8W5I7YdmUVVu4G92teE3rg3/aKaV00BRg8vJ3VXx3wc59Qj4em7zxQ5o0HvL8m1Aezw3RFMDPyVgA==} - '@push.rocks/webrequest@4.0.2': resolution: {integrity: sha512-rowzty+Q2papFBcnNYPcy+8CQJukSn/FGfQG8ap0bUgQUsx882u8kEyLM0Q+GlGHS5OiZ+Z0z5TZqLKlk3XHxA==} @@ -4303,7 +4300,7 @@ snapshots: '@push.rocks/smartfeed': 1.4.0 '@push.rocks/smartfile': 11.2.7 '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartlog-destination-devtools': 1.0.12 '@push.rocks/smartlog-interfaces': 3.0.2 '@push.rocks/smartmanifest': 2.0.2 @@ -4352,7 +4349,7 @@ snapshots: '@push.rocks/smartfile': 13.1.2 '@push.rocks/smartfs': 1.3.1 '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartlog-destination-devtools': 1.0.12 '@push.rocks/smartlog-interfaces': 3.0.2 '@push.rocks/smartmanifest': 2.0.2 @@ -4419,7 +4416,7 @@ snapshots: '@apiclient.xyz/cloudflare@7.1.0': dependencies: '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrequest': 5.0.1 '@push.rocks/smartstring': 4.1.0 @@ -5153,7 +5150,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartfile': 13.1.2 '@push.rocks/smartfs': 1.3.1 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 typescript: 5.9.3 @@ -5174,7 +5171,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartfs': 1.3.1 '@push.rocks/smartinteract': 2.0.16 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartlog-destination-local': 9.0.2 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 @@ -5200,7 +5197,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartfile': 13.1.2 '@push.rocks/smartfs': 1.3.1 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartnpm': 2.0.6 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartrequest': 5.0.1 @@ -5235,7 +5232,7 @@ snapshots: '@push.rocks/smartexpect': 2.5.0 '@push.rocks/smartfile': 11.2.7 '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartmongo': 2.2.0(socks@2.8.7) '@push.rocks/smartnetwork': 4.4.0 '@push.rocks/smartpath': 6.0.0 @@ -5281,7 +5278,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartfs': 1.3.1 '@push.rocks/smartinteract': 2.0.16 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartlog-destination-local': 9.0.2 '@push.rocks/smartshell': 3.3.0 '@push.rocks/smartwatch': 6.3.0 @@ -5714,7 +5711,7 @@ snapshots: '@push.rocks/qenv': 6.1.3 '@push.rocks/smartfile': 11.2.7 '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 @@ -5738,7 +5735,7 @@ snapshots: '@api.global/typedrequest': 3.2.6 '@configvault.io/interfaces': 1.0.17 '@push.rocks/smartfile': 11.2.7 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartacme@9.1.3(socks@2.8.7)': @@ -5749,7 +5746,7 @@ snapshots: '@push.rocks/smartdata': 7.0.15(socks@2.8.7) '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdns': 7.8.1 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartnetwork': 4.4.0 '@push.rocks/smartstring': 4.1.0 '@push.rocks/smarttime': 4.2.3 @@ -5859,7 +5856,7 @@ snapshots: '@push.rocks/smartcli@4.0.20': dependencies: '@push.rocks/lik': 6.2.2 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartobject': 1.0.12 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 @@ -5884,7 +5881,7 @@ snapshots: dependencies: '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartmongo': 2.2.0(socks@2.8.7) '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 @@ -5913,7 +5910,7 @@ snapshots: dependencies: '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartmongo': 2.2.0(socks@2.8.7) '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 @@ -6105,7 +6102,7 @@ snapshots: '@api.global/typedrequest-interfaces': 2.0.2 '@tsclass/tsclass': 4.4.4 - '@push.rocks/smartlog@3.1.11': + '@push.rocks/smartlog@3.2.0': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/consolecolor': 2.0.3 @@ -6114,8 +6111,8 @@ snapshots: '@push.rocks/smartfile': 11.2.7 '@push.rocks/smarthash': 3.2.6 '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smarttime': 4.1.1 - '@push.rocks/webrequest': 4.0.1 + '@push.rocks/smarttime': 4.2.3 + '@push.rocks/webrequest': 4.0.2 '@tsclass/tsclass': 9.3.0 '@push.rocks/smartmail@2.2.0': @@ -6152,7 +6149,7 @@ snapshots: '@push.rocks/smartmetrics@3.0.1': dependencies: '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartmime@1.0.6': dependencies: @@ -6221,7 +6218,7 @@ snapshots: dependencies: '@push.rocks/smartfile': 13.1.2 '@push.rocks/smartfs': 1.3.1 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartmail': 2.2.0 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartrust': 1.2.1 @@ -6327,7 +6324,7 @@ snapshots: '@push.rocks/smartproxy@25.7.8': dependencies: '@push.rocks/smartcrypto': 2.0.4 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartrust': 1.2.1 '@tsclass/tsclass': 9.3.0 minimatch: 10.2.1 @@ -6408,7 +6405,7 @@ snapshots: '@cfworker/json-schema': 4.1.1 '@push.rocks/lik': 6.2.2 '@push.rocks/smartenv': 6.0.0 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpath': 6.0.0 ws: 8.19.0 transitivePeerDependencies: @@ -6443,7 +6440,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartenv': 5.0.13 '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smarttime': 4.1.1 @@ -6558,7 +6555,7 @@ snapshots: '@design.estate/dees-element': 2.1.6 '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smarttime': 4.1.1 @@ -6574,7 +6571,7 @@ snapshots: '@design.estate/dees-element': 2.1.6 '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smarttime': 4.1.1 @@ -6590,7 +6587,7 @@ snapshots: '@design.estate/dees-element': 2.1.6 '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartlog': 3.1.11 + '@push.rocks/smartlog': 3.2.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smarttime': 4.2.3 @@ -6609,14 +6606,6 @@ snapshots: '@push.rocks/smartpromise': 4.2.3 '@push.rocks/webstore': 2.0.20 - '@push.rocks/webrequest@4.0.1': - dependencies: - '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartenv': 5.0.13 - '@push.rocks/smartjson': 5.2.0 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/webstore': 2.0.20 - '@push.rocks/webrequest@4.0.2': dependencies: '@push.rocks/smartdelay': 3.0.5 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 541b4f3..3bf5626 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.0.1', + version: '7.1.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 ec0c5cb..7059a1e 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -1055,7 +1055,25 @@ export class DcRouter { // Start the server await this.emailServer.start(); - + + // Wire delivery events to MetricsManager for time-series tracking + if (this.metricsManager && this.emailServer.deliverySystem) { + this.emailServer.deliverySystem.on('deliveryStart', (item: any) => { + this.metricsManager.trackEmailReceived(item?.from); + }); + this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => { + this.metricsManager.trackEmailSent(item?.to); + }); + this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => { + this.metricsManager.trackEmailFailed(item?.to, error?.message); + }); + } + if (this.metricsManager && this.emailServer) { + this.emailServer.on('bounceProcessed', () => { + this.metricsManager.trackEmailBounced(); + }); + } + logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`); } diff --git a/ts/logger.ts b/ts/logger.ts index c931eae..7eb23c1 100644 --- a/ts/logger.ts +++ b/ts/logger.ts @@ -1,5 +1,6 @@ import * as plugins from './plugins.js'; import { randomUUID } from 'node:crypto'; +import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer'; // Map NODE_ENV to valid TEnvironment const nodeEnv = process.env.NODE_ENV || 'production'; @@ -10,6 +11,9 @@ const envMap: Record = { 'production': '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({ logContext: { @@ -19,6 +23,9 @@ const baseLogger = new plugins.smartlog.Smartlog({ } }); +// Wire the buffer destination so all logs are captured +baseLogger.addLogDestination(logBuffer); + // Extended logger compatible with the original enhanced logger API class StandardLogger { private defaultContext: Record = {}; diff --git a/ts/monitoring/classes.metricsmanager.ts b/ts/monitoring/classes.metricsmanager.ts index 101bf0a..165f255 100644 --- a/ts/monitoring/classes.metricsmanager.ts +++ b/ts/monitoring/classes.metricsmanager.ts @@ -1,6 +1,7 @@ import * as plugins from '../plugins.js'; import { DcRouter } from '../classes.dcrouter.js'; import { MetricsCache } from './classes.metricscache.js'; +import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js'; export class MetricsManager { private logger: plugins.smartlog.Smartlog; @@ -37,6 +38,10 @@ export class MetricsManager { responseTimes: [] as number[], // Track response times in ms }; + // Per-minute time-series buckets for charts + private emailMinuteBuckets = new Map(); + private dnsMinuteBuckets = new Map(); + // Track security-specific metrics private securityMetrics = { blockedIPs: 0, @@ -227,20 +232,45 @@ export class MetricsManager { }); } + /** + * Sync security metrics from the SecurityLogger singleton (last 24h). + * Called before returning security stats so counters reflect real events. + */ + private syncFromSecurityLogger(): void { + try { + const securityLogger = SecurityLogger.getInstance(); + const summary = securityLogger.getEventsSummary(86400000); // last 24h + + this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0; + this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0; + this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC + this.securityMetrics.authFailures = + summary.byType[SecurityEventType.AUTHENTICATION] || 0; + this.securityMetrics.blockedIPs = + (summary.byType[SecurityEventType.IP_REPUTATION] || 0) + + (summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0); + } catch { + // SecurityLogger may not be initialized yet — ignore + } + } + // Get security metrics public async getSecurityStats() { return this.metricsCache.get('securityStats', () => { + // Sync counters from the real SecurityLogger events + this.syncFromSecurityLogger(); + // Get recent incidents (last 20) const recentIncidents = this.securityMetrics.incidents.slice(-20); - + return { blockedIPs: this.securityMetrics.blockedIPs, authFailures: this.securityMetrics.authFailures, spamDetected: this.securityMetrics.spamDetected, malwareDetected: this.securityMetrics.malwareDetected, phishingDetected: this.securityMetrics.phishingDetected, - totalThreatsBlocked: this.securityMetrics.spamDetected + - this.securityMetrics.malwareDetected + + totalThreatsBlocked: this.securityMetrics.spamDetected + + this.securityMetrics.malwareDetected + this.securityMetrics.phishingDetected, recentIncidents, }; @@ -275,6 +305,7 @@ export class MetricsManager { // Email event tracking methods public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void { this.emailMetrics.sentToday++; + this.incrementEmailBucket('sent'); if (recipient) { const count = this.emailMetrics.recipients.get(recipient) || 0; @@ -311,6 +342,7 @@ export class MetricsManager { public trackEmailReceived(sender?: string): void { this.emailMetrics.receivedToday++; + this.incrementEmailBucket('received'); this.emailMetrics.recentActivity.push({ timestamp: Date.now(), @@ -326,6 +358,7 @@ export class MetricsManager { public trackEmailFailed(recipient?: string, reason?: string): void { this.emailMetrics.failedToday++; + this.incrementEmailBucket('failed'); this.emailMetrics.recentActivity.push({ timestamp: Date.now(), @@ -361,6 +394,7 @@ export class MetricsManager { // DNS event tracking methods public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void { this.dnsMetrics.totalQueries++; + this.incrementDnsBucket(); if (cacheHit) { this.dnsMetrics.cacheHits++; @@ -547,4 +581,90 @@ export class MetricsManager { }; }, 200); // Use 200ms cache for more frequent updates } + + // --- Time-series helpers --- + + private static minuteKey(ts: number = Date.now()): number { + return Math.floor(ts / 60000) * 60000; + } + + private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void { + const key = MetricsManager.minuteKey(); + let bucket = this.emailMinuteBuckets.get(key); + if (!bucket) { + bucket = { sent: 0, received: 0, failed: 0 }; + this.emailMinuteBuckets.set(key, bucket); + } + bucket[field]++; + } + + private incrementDnsBucket(): void { + const key = MetricsManager.minuteKey(); + let bucket = this.dnsMinuteBuckets.get(key); + if (!bucket) { + bucket = { queries: 0 }; + this.dnsMinuteBuckets.set(key, bucket); + } + bucket.queries++; + } + + private pruneOldBuckets(): void { + const cutoff = Date.now() - 86400000; // 24h + for (const key of this.emailMinuteBuckets.keys()) { + if (key < cutoff) this.emailMinuteBuckets.delete(key); + } + for (const key of this.dnsMinuteBuckets.keys()) { + if (key < cutoff) this.dnsMinuteBuckets.delete(key); + } + } + + /** + * Get email time-series data for the last N hours, aggregated per minute. + */ + public getEmailTimeSeries(hours: number = 24): { + sent: Array<{ timestamp: number; value: number }>; + received: Array<{ timestamp: number; value: number }>; + failed: Array<{ timestamp: number; value: number }>; + } { + this.pruneOldBuckets(); + const cutoff = Date.now() - hours * 3600000; + const sent: Array<{ timestamp: number; value: number }> = []; + const received: Array<{ timestamp: number; value: number }> = []; + const failed: Array<{ timestamp: number; value: number }> = []; + + const sortedKeys = Array.from(this.emailMinuteBuckets.keys()) + .filter((k) => k >= cutoff) + .sort((a, b) => a - b); + + for (const key of sortedKeys) { + const bucket = this.emailMinuteBuckets.get(key)!; + sent.push({ timestamp: key, value: bucket.sent }); + received.push({ timestamp: key, value: bucket.received }); + failed.push({ timestamp: key, value: bucket.failed }); + } + + return { sent, received, failed }; + } + + /** + * Get DNS time-series data for the last N hours, aggregated per minute. + */ + public getDnsTimeSeries(hours: number = 24): { + queries: Array<{ timestamp: number; value: number }>; + } { + this.pruneOldBuckets(); + const cutoff = Date.now() - hours * 3600000; + const queries: Array<{ timestamp: number; value: number }> = []; + + const sortedKeys = Array.from(this.dnsMinuteBuckets.keys()) + .filter((k) => k >= cutoff) + .sort((a, b) => a - b); + + for (const key of sortedKeys) { + const bucket = this.dnsMinuteBuckets.get(key)!; + queries.push({ timestamp: key, value: bucket.queries }); + } + + return { queries }; + } } \ No newline at end of file diff --git a/ts/opsserver/handlers/logs.handler.ts b/ts/opsserver/handlers/logs.handler.ts index f934567..0b9198e 100644 --- a/ts/opsserver/handlers/logs.handler.ts +++ b/ts/opsserver/handlers/logs.handler.ts @@ -1,6 +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'; export class LogsHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); @@ -64,6 +65,32 @@ export class LogsHandler { ); } + private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' { + switch (smartlogLevel) { + case 'silly': + case 'debug': + return 'debug'; + case 'warn': + return 'warn'; + case 'error': + return 'error'; + default: + return 'info'; + } + } + + private static deriveCategory( + zone?: string, + message?: string + ): 'smtp' | 'dns' | 'security' | 'system' | 'email' { + const msg = (message || '').toLowerCase(); + if (msg.includes('[security:') || msg.includes('security')) return 'security'; + if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email'; + if (zone === 'dns' || msg.includes('dns')) return 'dns'; + if (msg.includes('smtp')) return 'smtp'; + return 'system'; + } + private async getRecentLogs( level?: 'error' | 'warn' | 'info' | 'debug', category?: 'smtp' | 'dns' | 'security' | 'system' | 'email', @@ -78,42 +105,64 @@ export class LogsHandler { message: string; metadata?: any; }>> { - // TODO: Implement actual log retrieval from storage or logger - // For now, return mock data - const mockLogs: Array<{ + // Compute a timestamp cutoff from timeRange + let since: number | undefined; + if (timeRange) { + const rangeMs: Record = { + '1h': 3600000, + '6h': 21600000, + '24h': 86400000, + '7d': 604800000, + '30d': 2592000000, + }; + since = Date.now() - (rangeMs[timeRange] || 86400000); + } + + // Map the UI level to smartlog levels for filtering + const smartlogLevels: string[] | undefined = level + ? level === 'debug' + ? ['debug', 'silly'] + : level === 'info' + ? ['info', 'ok', 'success', 'note', 'lifecycle'] + : [level] + : undefined; + + // Fetch a larger batch from buffer, then apply category filter client-side + const rawEntries = logBuffer.getEntries({ + level: smartlogLevels as any, + search, + since, + limit: limit * 3, // over-fetch to compensate for category filtering + offset: 0, + }); + + // Map ILogPackage → UI log format and apply category filter + const mapped: Array<{ timestamp: number; level: 'debug' | 'info' | 'warn' | 'error'; category: 'smtp' | 'dns' | 'security' | 'system' | 'email'; message: string; metadata?: any; }> = []; - - const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email']; - const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug']; - const now = Date.now(); - - // Generate some mock log entries - for (let i = 0; i < 50; i++) { - const mockCategory = categories[Math.floor(Math.random() * categories.length)]; - const mockLevel = levels[Math.floor(Math.random() * levels.length)]; - - // Filter by requested criteria - if (level && mockLevel !== level) continue; - if (category && mockCategory !== category) continue; - - mockLogs.push({ - timestamp: now - (i * 60000), // 1 minute apart - level: mockLevel, - category: mockCategory, - message: `Sample log message ${i} from ${mockCategory}`, - metadata: { - requestId: plugins.uuid.v4(), - }, + + for (const pkg of rawEntries) { + const uiLevel = LogsHandler.mapLogLevel(pkg.level); + const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message); + + if (category && uiCategory !== category) continue; + + mapped.push({ + timestamp: pkg.timestamp, + level: uiLevel, + category: uiCategory, + message: pkg.message, + metadata: pkg.data, }); + + if (mapped.length >= limit) break; } - - // Apply pagination - return mockLogs.slice(offset, offset + limit); + + return mapped; } private setupLogStream( diff --git a/ts/opsserver/handlers/stats.handler.ts b/ts/opsserver/handlers/stats.handler.ts index 0cb424c..b2849cb 100644 --- a/ts/opsserver/handlers/stats.handler.ts +++ b/ts/opsserver/handlers/stats.handler.ts @@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; import { MetricsManager } from '../../monitoring/index.js'; +import { SecurityLogger } from '../../security/classes.securitylogger.js'; export class StatsHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); @@ -203,6 +204,11 @@ export class StatsHandler { if (sections.email) { promises.push( this.collectEmailStats().then(stats => { + // Get time-series data from MetricsManager + const timeSeries = this.opsServerRef.dcRouterRef.metricsManager + ? this.opsServerRef.dcRouterRef.metricsManager.getEmailTimeSeries(24) + : undefined; + metrics.email = { sent: stats.sentToday, received: stats.receivedToday, @@ -212,6 +218,7 @@ export class StatsHandler { averageDeliveryTime: 0, deliveryRate: stats.deliveryRate, bounceRate: stats.bounceRate, + timeSeries, }; }) ); @@ -220,6 +227,11 @@ export class StatsHandler { if (sections.dns) { promises.push( this.collectDnsStats().then(stats => { + // Get time-series data from MetricsManager + const timeSeries = this.opsServerRef.dcRouterRef.metricsManager + ? this.opsServerRef.dcRouterRef.metricsManager.getDnsTimeSeries(24) + : undefined; + metrics.dns = { totalQueries: stats.totalQueries, cacheHits: stats.cacheHits, @@ -228,6 +240,7 @@ export class StatsHandler { activeDomains: stats.topDomains.length, averageResponseTime: 0, queryTypes: stats.queryTypes, + timeSeries, }; }) ); @@ -236,6 +249,19 @@ export class StatsHandler { if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) { promises.push( this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => { + // Get recent events from the SecurityLogger singleton + const securityLogger = SecurityLogger.getInstance(); + const recentEvents = securityLogger.getRecentEvents(50).map((evt) => ({ + timestamp: evt.timestamp, + level: evt.level, + type: evt.type, + message: evt.message, + details: evt.details, + ipAddress: evt.ipAddress, + domain: evt.domain, + success: evt.success, + })); + metrics.security = { blockedIPs: stats.blockedIPs, reputationScores: {}, @@ -244,6 +270,7 @@ export class StatsHandler { phishingDetected: stats.phishingDetected, authenticationFailures: stats.authFailures, suspiciousActivities: stats.totalThreatsBlocked, + recentEvents, }; }) ); diff --git a/ts_interfaces/data/stats.ts b/ts_interfaces/data/stats.ts index 2be4949..79d6c1a 100644 --- a/ts_interfaces/data/stats.ts +++ b/ts_interfaces/data/stats.ts @@ -26,6 +26,11 @@ export interface IServerStats { }; } +export interface ITimeSeriesPoint { + timestamp: number; + value: number; +} + export interface IEmailStats { sent: number; received: number; @@ -35,6 +40,11 @@ export interface IEmailStats { averageDeliveryTime: number; deliveryRate: number; bounceRate: number; + timeSeries?: { + sent: ITimeSeriesPoint[]; + received: ITimeSeriesPoint[]; + failed: ITimeSeriesPoint[]; + }; } export interface IDnsStats { @@ -47,6 +57,9 @@ export interface IDnsStats { queryTypes: { [key: string]: number; }; + timeSeries?: { + queries: ITimeSeriesPoint[]; + }; } export interface IRateLimitInfo { @@ -58,6 +71,17 @@ export interface IRateLimitInfo { blocked: boolean; } +export interface ISecurityEvent { + timestamp: number; + level: string; + type: string; + message: string; + details?: any; + ipAddress?: string; + domain?: string; + success?: boolean; +} + export interface ISecurityMetrics { blockedIPs: string[]; reputationScores: { @@ -68,6 +92,7 @@ export interface ISecurityMetrics { phishingDetected: number; authenticationFailures: number; suspiciousActivities: number; + recentEvents?: ISecurityEvent[]; } export interface ILogEntry { diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 541b4f3..3bf5626 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.0.1', + version: '7.1.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/ops-view-logs.ts b/ts_web/elements/ops-view-logs.ts index 60fed92..7eabfb3 100644 --- a/ts_web/elements/ops-view-logs.ts +++ b/ts_web/elements/ops-view-logs.ts @@ -20,6 +20,15 @@ export class OpsViewLogs extends DeesElement { filters: {}, }; + @state() + accessor filterLevel: string | undefined; + + @state() + accessor filterCategory: string | undefined; + + @state() + accessor filterLimit: number = 100; + constructor() { super(); const subscription = appstate.logStatePart @@ -174,29 +183,43 @@ export class OpsViewLogs extends DeesElement { `; } + async connectedCallback() { + super.connectedCallback(); + // Auto-fetch logs when the view mounts + this.fetchLogs(); + } + private async fetchLogs() { - const filters = this.getActiveFilters(); await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { - limit: filters.limit || 100, - level: filters.level as 'debug' | 'info' | 'warn' | 'error' | undefined, - category: filters.category as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined, + limit: this.filterLimit, + level: this.filterLevel as 'debug' | 'info' | 'warn' | 'error' | undefined, + category: this.filterCategory as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined, }); } private updateFilter(type: string, value: string) { - if (value === 'all') { - value = undefined; + const resolved = value === 'all' ? undefined : value; + + switch (type) { + case 'level': + this.filterLevel = resolved; + break; + case 'category': + this.filterCategory = resolved; + break; + case 'limit': + this.filterLimit = resolved ? parseInt(resolved, 10) : 100; + break; } - - // Update filters then fetch logs + this.fetchLogs(); } private getActiveFilters() { return { - level: this.logState.filters.level?.[0], - category: this.logState.filters.category?.[0], - limit: 100, + level: this.filterLevel, + category: this.filterCategory, + limit: this.filterLimit, }; } diff --git a/ts_web/elements/ops-view-overview.ts b/ts_web/elements/ops-view-overview.ts index abe308e..8167d20 100644 --- a/ts_web/elements/ops-view-overview.ts +++ b/ts_web/elements/ops-view-overview.ts @@ -26,14 +26,36 @@ export class OpsViewOverview extends DeesElement { error: null, }; + @state() + accessor logState: appstate.ILogState = { + recentLogs: [], + isStreaming: false, + filters: {}, + }; + constructor() { super(); - const subscription = appstate.statsStatePart + const statsSub = appstate.statsStatePart .select((stateArg) => stateArg) .subscribe((statsState) => { this.statsState = statsState; }); - this.rxSubscriptions.push(subscription); + this.rxSubscriptions.push(statsSub); + + const logSub = appstate.logStatePart + .select((stateArg) => stateArg) + .subscribe((logState) => { + this.logState = logState; + }); + this.rxSubscriptions.push(logSub); + } + + async connectedCallback() { + super.connectedCallback(); + // Ensure logs are fetched for the overview charts + if (this.logState.recentLogs.length === 0) { + appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 }); + } } public static styles = [ @@ -96,10 +118,24 @@ export class OpsViewOverview extends DeesElement { ${this.renderDnsStats()}
- - - - + `${val}`} + > + `${val}`} + > + +
`} `; @@ -337,4 +373,42 @@ export class OpsViewOverview extends DeesElement { `; } + + // --- Chart data helpers --- + + private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> { + return this.logState.recentLogs.map((log) => ({ + timestamp: new Date(log.timestamp).toISOString(), + level: log.level as 'debug' | 'info' | 'warn' | 'error', + message: log.message, + source: log.category, + })); + } + + private getSecurityAlertEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> { + const events: any[] = this.statsState.securityMetrics?.recentEvents || []; + return events.map((evt: any) => ({ + timestamp: new Date(evt.timestamp).toISOString(), + level: evt.level === 'critical' || evt.level === 'error' ? 'error' as const : evt.level === 'warn' ? 'warn' as const : 'info' as const, + message: evt.message, + source: evt.type, + })); + } + + private getEmailTrafficSeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> { + const ts = this.statsState.emailStats?.timeSeries; + if (!ts) return []; + return [ + { name: 'Sent', color: '#22c55e', data: (ts.sent || []).map((p: any) => ({ x: p.timestamp, y: p.value })) }, + { name: 'Received', color: '#3b82f6', data: (ts.received || []).map((p: any) => ({ x: p.timestamp, y: p.value })) }, + ]; + } + + private getDnsQuerySeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> { + const ts = this.statsState.dnsStats?.timeSeries; + if (!ts) return []; + return [ + { name: 'Queries', color: '#8b5cf6', data: (ts.queries || []).map((p: any) => ({ x: p.timestamp, y: p.value })) }, + ]; + } } \ No newline at end of file diff --git a/ts_web/elements/ops-view-security.ts b/ts_web/elements/ops-view-security.ts index 5a5c85a..8123585 100644 --- a/ts_web/elements/ops-view-security.ts +++ b/ts_web/elements/ops-view-security.ts @@ -249,7 +249,14 @@ export class OpsViewSecurity extends DeesElement { private renderOverview(metrics: any) { const threatLevel = this.calculateThreatLevel(metrics); const threatScore = this.getThreatScore(metrics); - + + // Derive active sessions from recent successful auth events (last hour) + const allEvents: any[] = metrics.recentEvents || []; + const oneHourAgo = Date.now() - 3600000; + const recentAuthSuccesses = allEvents.filter( + (evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo + ).length; + const tiles: IStatsTile[] = [ { id: 'threatLevel', @@ -271,7 +278,7 @@ export class OpsViewSecurity extends DeesElement { { id: 'blockedThreats', title: 'Blocked Threats', - value: metrics.blockedIPs.length + metrics.spamDetected, + value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected, type: 'number', icon: 'lucide:ShieldCheck', color: '#ef4444', @@ -280,11 +287,11 @@ export class OpsViewSecurity extends DeesElement { { id: 'activeSessions', title: 'Active Sessions', - value: 0, + value: recentAuthSuccesses, type: 'number', icon: 'lucide:Users', color: '#22c55e', - description: 'Current authenticated sessions', + description: 'Authenticated in last hour', }, { id: 'authFailures', @@ -349,6 +356,11 @@ export class OpsViewSecurity extends DeesElement { } private renderAuthentication(metrics: any) { + // Derive auth events from recentEvents + const allEvents: any[] = metrics.recentEvents || []; + const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication'); + const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length; + const tiles: IStatsTile[] = [ { id: 'authFailures', @@ -362,7 +374,7 @@ export class OpsViewSecurity extends DeesElement { { id: 'successfulLogins', title: 'Successful Logins', - value: 0, + value: successfulLogins, type: 'number', icon: 'lucide:Lock', color: '#22c55e', @@ -370,6 +382,15 @@ export class OpsViewSecurity extends DeesElement { }, ]; + // Map auth events to login history table data + const loginHistory = authEvents.map((evt: any) => ({ + timestamp: evt.timestamp, + username: evt.details?.username || 'unknown', + ipAddress: evt.ipAddress || 'unknown', + success: evt.success ?? false, + reason: evt.success ? '' : evt.message || 'Authentication failed', + })); + return html` ({ 'Time': new Date(item.timestamp).toLocaleString(), 'Username': item.username, @@ -483,48 +504,38 @@ export class OpsViewSecurity extends DeesElement { private getThreatScore(metrics: any): number { // Simple scoring algorithm let score = 100; - score -= metrics.blockedIPs.length * 2; - score -= metrics.authenticationFailures * 1; - score -= metrics.spamDetected * 0.5; - score -= metrics.malwareDetected * 3; - score -= metrics.phishingDetected * 3; - score -= metrics.suspiciousActivities * 2; + const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0); + score -= blockedCount * 2; + score -= (metrics.authenticationFailures || 0) * 1; + score -= (metrics.spamDetected || 0) * 0.5; + score -= (metrics.malwareDetected || 0) * 3; + score -= (metrics.phishingDetected || 0) * 3; + score -= (metrics.suspiciousActivities || 0) * 2; return Math.max(0, Math.min(100, Math.round(score))); } private getSecurityEvents(metrics: any): any[] { - // Mock data - in real implementation, this would come from the server - return [ - { - timestamp: Date.now() - 1000 * 60 * 5, - event: 'Multiple failed login attempts', - severity: 'warning', - details: 'IP: 192.168.1.100', - }, - { - timestamp: Date.now() - 1000 * 60 * 15, - event: 'SPF check failed', - severity: 'medium', - details: 'Domain: example.com', - }, - { - timestamp: Date.now() - 1000 * 60 * 30, - event: 'IP blocked due to spam', - severity: 'high', - details: 'IP: 10.0.0.1', - }, - ]; + const events: any[] = metrics.recentEvents || []; + return events.map((evt: any) => ({ + timestamp: evt.timestamp, + event: evt.message, + severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info', + details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type, + })); } private async clearBlockedIPs() { - console.log('Clear blocked IPs'); + // SmartProxy manages IP blocking — not yet exposed via API + alert('Clearing blocked IPs is not yet supported from the UI.'); } private async unblockIP(ip: string) { - console.log('Unblock IP:', ip); + // SmartProxy manages IP blocking — not yet exposed via API + alert(`Unblocking IP ${ip} is not yet supported from the UI.`); } private async saveEmailSecuritySettings() { - console.log('Save email security settings'); + // Config is read-only from the UI for now + alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.'); } } \ No newline at end of file