feat(opsserver): add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-03 - 12.6.6 - fix(deps)
|
||||||
bump @design.estate/dees-catalog to ^3.52.3
|
bump @design.estate/dees-catalog to ^3.52.3
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.6.6',
|
version: '12.7.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export class LogsHandler {
|
|||||||
} {
|
} {
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let logIndex = 0;
|
let lastTimestamp = Date.now();
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -284,40 +284,44 @@ export class LogsHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow mode, simulate real-time log streaming
|
// For follow mode, tail real log entries from the in-memory buffer
|
||||||
intervalId = setInterval(async () => {
|
intervalId = setInterval(async () => {
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
// Guard: clear interval if stop() was called between ticks
|
|
||||||
clearInterval(intervalId!);
|
clearInterval(intervalId!);
|
||||||
intervalId = null;
|
intervalId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
// Fetch new entries since last poll
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const rawEntries = logBuffer.getEntries({
|
||||||
|
since: lastTimestamp,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
if (rawEntries.length === 0) return;
|
||||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
|
||||||
|
|
||||||
// Filter by requested criteria
|
for (const raw of rawEntries) {
|
||||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
const mappedLevel = LogsHandler.mapLogLevel(raw.level);
|
||||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
const mappedCategory = LogsHandler.deriveCategory(
|
||||||
|
(raw as any).context?.zone,
|
||||||
|
raw.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (levelFilter && !levelFilter.includes(mappedLevel)) continue;
|
||||||
|
if (categoryFilter && !categoryFilter.includes(mappedCategory)) continue;
|
||||||
|
|
||||||
const logEntry = {
|
const logEntry = {
|
||||||
timestamp: Date.now(),
|
timestamp: raw.timestamp || Date.now(),
|
||||||
level: mockLevel,
|
level: mappedLevel,
|
||||||
category: mockCategory,
|
category: mappedCategory,
|
||||||
message: `Real-time log ${logIndex++} from ${mockCategory}`,
|
message: raw.message,
|
||||||
metadata: {
|
metadata: (raw as any).data,
|
||||||
requestId: plugins.uuid.v4(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logData = JSON.stringify(logEntry);
|
const logData = JSON.stringify(logEntry);
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
try {
|
try {
|
||||||
// Use a timeout to detect hung streams (sendData can hang if the
|
|
||||||
// VirtualStream's keepAlive loop has ended)
|
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||||
@@ -331,6 +335,14 @@ export class LogsHandler {
|
|||||||
} catch {
|
} catch {
|
||||||
// Stream closed, errored, or timed out — clean up
|
// Stream closed, errored, or timed out — clean up
|
||||||
stop();
|
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);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { MetricsManager } from '../../monitoring/index.js';
|
import { MetricsManager } from '../../monitoring/index.js';
|
||||||
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||||
|
import { commitinfo } from '../../00_commitinfo_data.js';
|
||||||
|
|
||||||
export class StatsHandler {
|
export class StatsHandler {
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
@@ -158,7 +159,7 @@ export class StatsHandler {
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
version: '2.12.0', // TODO: Get from package.json
|
version: commitinfo.version,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -315,6 +316,46 @@ 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);
|
await Promise.all(promises);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -198,3 +198,23 @@ export interface IBackendInfo {
|
|||||||
h3Port: number | null;
|
h3Port: number | null;
|
||||||
cacheAgeSecs: 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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export interface IReq_GetCombinedMetrics {
|
|||||||
dns?: boolean;
|
dns?: boolean;
|
||||||
security?: boolean;
|
security?: boolean;
|
||||||
network?: boolean;
|
network?: boolean;
|
||||||
|
radius?: boolean;
|
||||||
|
vpn?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -19,6 +21,8 @@ export interface IReq_GetCombinedMetrics {
|
|||||||
dns?: data.IDnsStats;
|
dns?: data.IDnsStats;
|
||||||
security?: data.ISecurityMetrics;
|
security?: data.ISecurityMetrics;
|
||||||
network?: data.INetworkMetrics;
|
network?: data.INetworkMetrics;
|
||||||
|
radius?: data.IRadiusStats;
|
||||||
|
vpn?: data.IVpnStats;
|
||||||
};
|
};
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.6.6',
|
version: '12.7.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface IStatsState {
|
|||||||
emailStats: interfaces.data.IEmailStats | null;
|
emailStats: interfaces.data.IEmailStats | null;
|
||||||
dnsStats: interfaces.data.IDnsStats | null;
|
dnsStats: interfaces.data.IDnsStats | null;
|
||||||
securityMetrics: interfaces.data.ISecurityMetrics | null;
|
securityMetrics: interfaces.data.ISecurityMetrics | null;
|
||||||
|
radiusStats: interfaces.data.IRadiusStats | null;
|
||||||
|
vpnStats: interfaces.data.IVpnStats | null;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -91,6 +93,8 @@ export const statsStatePart = await appState.getStatePart<IStatsState>(
|
|||||||
emailStats: null,
|
emailStats: null,
|
||||||
dnsStats: null,
|
dnsStats: null,
|
||||||
securityMetrics: null,
|
securityMetrics: null,
|
||||||
|
radiusStats: null,
|
||||||
|
vpnStats: null,
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -319,6 +323,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
|
|||||||
dns: true,
|
dns: true,
|
||||||
security: true,
|
security: true,
|
||||||
network: false, // Network is fetched separately for the network view
|
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,
|
emailStats: combinedResponse.metrics.email || currentState.emailStats,
|
||||||
dnsStats: combinedResponse.metrics.dns || currentState.dnsStats,
|
dnsStats: combinedResponse.metrics.dns || currentState.dnsStats,
|
||||||
securityMetrics: combinedResponse.metrics.security || currentState.securityMetrics,
|
securityMetrics: combinedResponse.metrics.security || currentState.securityMetrics,
|
||||||
|
radiusStats: combinedResponse.metrics.radius || currentState.radiusStats,
|
||||||
|
vpnStats: combinedResponse.metrics.vpn || currentState.vpnStats,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1781,6 +1789,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
dns: true,
|
dns: true,
|
||||||
security: true,
|
security: true,
|
||||||
network: currentView === 'network', // Only fetch network if on network view
|
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,
|
emailStats: combinedResponse.metrics.email || currentStatsState.emailStats,
|
||||||
dnsStats: combinedResponse.metrics.dns || currentStatsState.dnsStats,
|
dnsStats: combinedResponse.metrics.dns || currentStatsState.dnsStats,
|
||||||
securityMetrics: combinedResponse.metrics.security || currentStatsState.securityMetrics,
|
securityMetrics: combinedResponse.metrics.security || currentStatsState.securityMetrics,
|
||||||
|
radiusStats: combinedResponse.metrics.radius || currentStatsState.radiusStats,
|
||||||
|
vpnStats: combinedResponse.metrics.vpn || currentStatsState.vpnStats,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
emailStats: null,
|
emailStats: null,
|
||||||
dnsStats: null,
|
dnsStats: null,
|
||||||
securityMetrics: null,
|
securityMetrics: null,
|
||||||
|
radiusStats: null,
|
||||||
|
vpnStats: null,
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -117,6 +119,10 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
|
|
||||||
${this.renderDnsStats()}
|
${this.renderDnsStats()}
|
||||||
|
|
||||||
|
${this.renderRadiusStats()}
|
||||||
|
|
||||||
|
${this.renderVpnStats()}
|
||||||
|
|
||||||
<div class="chartGrid">
|
<div class="chartGrid">
|
||||||
<dees-chart-area
|
<dees-chart-area
|
||||||
.label=${'Email Traffic (24h)'}
|
.label=${'Email Traffic (24h)'}
|
||||||
@@ -378,6 +384,97 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderRadiusStats(): TemplateResult {
|
||||||
|
if (!this.statsState.radiusStats) return html``;
|
||||||
|
|
||||||
|
const stats = this.statsState.radiusStats;
|
||||||
|
const authTotal = stats.authRequests || 0;
|
||||||
|
const acceptRate = authTotal > 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`
|
||||||
|
<h2>RADIUS Statistics</h2>
|
||||||
|
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
<h2>VPN Statistics</h2>
|
||||||
|
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Chart data helpers ---
|
// --- Chart data helpers ---
|
||||||
|
|
||||||
private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
emailStats: null,
|
emailStats: null,
|
||||||
dnsStats: null,
|
dnsStats: null,
|
||||||
securityMetrics: null,
|
securityMetrics: null,
|
||||||
|
radiusStats: null,
|
||||||
|
vpnStats: null,
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user