diff --git a/changelog.md b/changelog.md index 44d9484..1a8a969 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-03 - 12.7.0 - feat(opsserver) +add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode + +- Expose RADIUS and VPN sections in the combined stats API and shared TypeScript interfaces +- Populate frontend app state and overview tiles with RADIUS authentication, session, traffic, and VPN client metrics +- Replace simulated follow-mode log events with real log buffer tailing and timestamp-based incremental streaming +- Use commit metadata for reported server version instead of a hardcoded value + ## 2026-04-03 - 12.6.6 - fix(deps) bump @design.estate/dees-catalog to ^3.52.3 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 259e416..97dad9a 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: '12.6.6', + version: '12.7.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/opsserver/handlers/logs.handler.ts b/ts/opsserver/handlers/logs.handler.ts index 011f9e5..9b0b259 100644 --- a/ts/opsserver/handlers/logs.handler.ts +++ b/ts/opsserver/handlers/logs.handler.ts @@ -255,7 +255,7 @@ export class LogsHandler { } { let intervalId: NodeJS.Timeout | null = null; let stopped = false; - let logIndex = 0; + let lastTimestamp = Date.now(); const stop = () => { stopped = true; @@ -284,53 +284,65 @@ export class LogsHandler { return; } - // For follow mode, simulate real-time log streaming + // For follow mode, tail real log entries from the in-memory buffer intervalId = setInterval(async () => { if (stopped) { - // Guard: clear interval if stop() was called between ticks clearInterval(intervalId!); intervalId = null; return; } - const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email']; - const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug']; + // Fetch new entries since last poll + const rawEntries = logBuffer.getEntries({ + since: lastTimestamp, + limit: 50, + }); - const mockCategory = categories[Math.floor(Math.random() * categories.length)]; - const mockLevel = levels[Math.floor(Math.random() * levels.length)]; + if (rawEntries.length === 0) return; - // Filter by requested criteria - if (levelFilter && !levelFilter.includes(mockLevel)) return; - if (categoryFilter && !categoryFilter.includes(mockCategory)) return; + for (const raw of rawEntries) { + const mappedLevel = LogsHandler.mapLogLevel(raw.level); + const mappedCategory = LogsHandler.deriveCategory( + (raw as any).context?.zone, + raw.message, + ); - const logEntry = { - timestamp: Date.now(), - level: mockLevel, - category: mockCategory, - message: `Real-time log ${logIndex++} from ${mockCategory}`, - metadata: { - requestId: plugins.uuid.v4(), - }, - }; + // Apply filters + if (levelFilter && !levelFilter.includes(mappedLevel)) continue; + if (categoryFilter && !categoryFilter.includes(mappedCategory)) continue; - const logData = JSON.stringify(logEntry); - const encoder = new TextEncoder(); - try { - // Use a timeout to detect hung streams (sendData can hang if the - // VirtualStream's keepAlive loop has ended) - let timeoutHandle: ReturnType; - await Promise.race([ - virtualStream.sendData(encoder.encode(logData)).then((result) => { - clearTimeout(timeoutHandle); - return result; - }), - new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000); - }), - ]); - } catch { - // Stream closed, errored, or timed out — clean up - stop(); + const logEntry = { + timestamp: raw.timestamp || Date.now(), + level: mappedLevel, + category: mappedCategory, + message: raw.message, + metadata: (raw as any).data, + }; + + const logData = JSON.stringify(logEntry); + const encoder = new TextEncoder(); + try { + let timeoutHandle: ReturnType; + await Promise.race([ + virtualStream.sendData(encoder.encode(logData)).then((result) => { + clearTimeout(timeoutHandle); + return result; + }), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000); + }), + ]); + } catch { + // Stream closed, errored, or timed out — clean up + stop(); + return; + } + } + + // Advance the watermark past all entries we just processed + const newest = rawEntries[rawEntries.length - 1]; + if (newest.timestamp && newest.timestamp >= lastTimestamp) { + lastTimestamp = newest.timestamp + 1; } }, 2000); }; diff --git a/ts/opsserver/handlers/stats.handler.ts b/ts/opsserver/handlers/stats.handler.ts index 182c075..054753a 100644 --- a/ts/opsserver/handlers/stats.handler.ts +++ b/ts/opsserver/handlers/stats.handler.ts @@ -3,6 +3,7 @@ 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'; +import { commitinfo } from '../../00_commitinfo_data.js'; export class StatsHandler { constructor(private opsServerRef: OpsServer) { @@ -158,7 +159,7 @@ export class StatsHandler { }; return acc; }, {} as any), - version: '2.12.0', // TODO: Get from package.json + version: commitinfo.version, }, }; } @@ -314,7 +315,47 @@ export class StatsHandler { })() ); } - + + if (sections.radius) { + promises.push( + (async () => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + if (!radiusServer) return; + const stats = radiusServer.getStats(); + const accountingStats = radiusServer.getAccountingManager().getStats(); + metrics.radius = { + running: stats.running, + uptime: stats.uptime, + authRequests: stats.authRequests, + authAccepts: stats.authAccepts, + authRejects: stats.authRejects, + accountingRequests: stats.accountingRequests, + activeSessions: stats.activeSessions, + totalInputBytes: accountingStats.totalInputBytes, + totalOutputBytes: accountingStats.totalOutputBytes, + }; + })() + ); + } + + if (sections.vpn) { + promises.push( + (async () => { + const vpnManager = this.opsServerRef.dcRouterRef.vpnManager; + const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig; + if (!vpnManager) return; + const connected = await vpnManager.getConnectedClients(); + metrics.vpn = { + running: vpnManager.running, + subnet: vpnManager.getSubnet(), + registeredClients: vpnManager.listClients().length, + connectedClients: connected.length, + wgListenPort: vpnConfig?.wgListenPort ?? 51820, + }; + })() + ); + } + await Promise.all(promises); return { diff --git a/ts_interfaces/data/stats.ts b/ts_interfaces/data/stats.ts index 35b4e2d..6b2df46 100644 --- a/ts_interfaces/data/stats.ts +++ b/ts_interfaces/data/stats.ts @@ -197,4 +197,24 @@ export interface IBackendInfo { h3ConsecutiveFailures: number | null; h3Port: number | null; cacheAgeSecs: number | null; +} + +export interface IRadiusStats { + running: boolean; + uptime: number; + authRequests: number; + authAccepts: number; + authRejects: number; + accountingRequests: number; + activeSessions: number; + totalInputBytes: number; + totalOutputBytes: number; +} + +export interface IVpnStats { + running: boolean; + subnet: string; + registeredClients: number; + connectedClients: number; + wgListenPort: number; } \ No newline at end of file diff --git a/ts_interfaces/requests/combined.stats.ts b/ts_interfaces/requests/combined.stats.ts index 9ac2e15..47b27db 100644 --- a/ts_interfaces/requests/combined.stats.ts +++ b/ts_interfaces/requests/combined.stats.ts @@ -10,6 +10,8 @@ export interface IReq_GetCombinedMetrics { dns?: boolean; security?: boolean; network?: boolean; + radius?: boolean; + vpn?: boolean; }; }; response: { @@ -19,6 +21,8 @@ export interface IReq_GetCombinedMetrics { dns?: data.IDnsStats; security?: data.ISecurityMetrics; network?: data.INetworkMetrics; + radius?: data.IRadiusStats; + vpn?: data.IVpnStats; }; timestamp: number; }; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 259e416..97dad9a 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: '12.6.6', + version: '12.7.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 130762a..70c66a9 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -15,6 +15,8 @@ export interface IStatsState { emailStats: interfaces.data.IEmailStats | null; dnsStats: interfaces.data.IDnsStats | null; securityMetrics: interfaces.data.ISecurityMetrics | null; + radiusStats: interfaces.data.IRadiusStats | null; + vpnStats: interfaces.data.IVpnStats | null; lastUpdated: number; isLoading: boolean; error: string | null; @@ -91,6 +93,8 @@ export const statsStatePart = await appState.getStatePart( emailStats: null, dnsStats: null, securityMetrics: null, + radiusStats: null, + vpnStats: null, lastUpdated: 0, isLoading: false, error: null, @@ -319,6 +323,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA dns: true, security: true, network: false, // Network is fetched separately for the network view + radius: true, + vpn: true, }, }); @@ -328,6 +334,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA emailStats: combinedResponse.metrics.email || currentState.emailStats, dnsStats: combinedResponse.metrics.dns || currentState.dnsStats, securityMetrics: combinedResponse.metrics.security || currentState.securityMetrics, + radiusStats: combinedResponse.metrics.radius || currentState.radiusStats, + vpnStats: combinedResponse.metrics.vpn || currentState.vpnStats, lastUpdated: Date.now(), isLoading: false, error: null, @@ -1781,6 +1789,8 @@ async function dispatchCombinedRefreshActionInner() { dns: true, security: true, network: currentView === 'network', // Only fetch network if on network view + radius: true, + vpn: true, }, }); @@ -1792,6 +1802,8 @@ async function dispatchCombinedRefreshActionInner() { 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, diff --git a/ts_web/elements/ops-view-overview.ts b/ts_web/elements/ops-view-overview.ts index 6f56f15..210661d 100644 --- a/ts_web/elements/ops-view-overview.ts +++ b/ts_web/elements/ops-view-overview.ts @@ -21,6 +21,8 @@ export class OpsViewOverview extends DeesElement { emailStats: null, dnsStats: null, securityMetrics: null, + radiusStats: null, + vpnStats: null, lastUpdated: 0, isLoading: false, error: null, @@ -117,6 +119,10 @@ export class OpsViewOverview extends DeesElement { ${this.renderDnsStats()} + ${this.renderRadiusStats()} + + ${this.renderVpnStats()} +
0 ? ((stats.authAccepts / authTotal) * 100).toFixed(1) : '0.0'; + + const tiles: IStatsTile[] = [ + { + id: 'radiusStatus', + title: 'RADIUS Status', + value: stats.running ? 'Running' : 'Stopped', + type: 'text', + icon: 'lucide:ShieldCheck', + color: stats.running ? '#22c55e' : '#ef4444', + description: stats.running ? `Uptime: ${this.formatUptime(stats.uptime / 1000)}` : undefined, + }, + { + id: 'authRequests', + title: 'Auth Requests', + value: stats.authRequests, + type: 'number', + icon: 'lucide:KeyRound', + color: '#3b82f6', + description: `Accept rate: ${acceptRate}% (${stats.authAccepts} / ${stats.authRejects} rejected)`, + }, + { + id: 'activeSessions', + title: 'Active Sessions', + value: stats.activeSessions, + type: 'number', + icon: 'lucide:Users', + color: '#8b5cf6', + }, + { + id: 'radiusTraffic', + title: 'Data Transfer', + value: this.formatBytes(stats.totalInputBytes + stats.totalOutputBytes), + type: 'text', + icon: 'lucide:ArrowLeftRight', + color: '#f59e0b', + description: `In: ${this.formatBytes(stats.totalInputBytes)} / Out: ${this.formatBytes(stats.totalOutputBytes)}`, + }, + ]; + + return html` +

RADIUS Statistics

+ + `; + } + + private renderVpnStats(): TemplateResult { + if (!this.statsState.vpnStats) return html``; + + const stats = this.statsState.vpnStats; + + const tiles: IStatsTile[] = [ + { + id: 'vpnStatus', + title: 'VPN Status', + value: stats.running ? 'Running' : 'Stopped', + type: 'text', + icon: 'lucide:Shield', + color: stats.running ? '#22c55e' : '#ef4444', + description: `Subnet: ${stats.subnet}`, + }, + { + id: 'connectedClients', + title: 'Connected Clients', + value: stats.connectedClients, + type: 'number', + icon: 'lucide:Wifi', + color: '#3b82f6', + description: `${stats.registeredClients} registered`, + }, + { + id: 'wgPort', + title: 'WireGuard Port', + value: stats.wgListenPort, + type: 'number', + icon: 'lucide:Network', + color: '#8b5cf6', + }, + ]; + + return html` +

VPN Statistics

+ + `; + } + // --- Chart data helpers --- private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> { diff --git a/ts_web/elements/ops-view-security.ts b/ts_web/elements/ops-view-security.ts index 8123585..bb19ddc 100644 --- a/ts_web/elements/ops-view-security.ts +++ b/ts_web/elements/ops-view-security.ts @@ -20,6 +20,8 @@ export class OpsViewSecurity extends DeesElement { emailStats: null, dnsStats: null, securityMetrics: null, + radiusStats: null, + vpnStats: null, lastUpdated: 0, isLoading: false, error: null,