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:
2026-04-03 14:11:17 +00:00
parent b3296c6522
commit f6a9e344e5
10 changed files with 237 additions and 41 deletions

View File

@@ -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<typeof setTimeout>;
await Promise.race([
virtualStream.sendData(encoder.encode(logData)).then((result) => {
clearTimeout(timeoutHandle);
return result;
}),
new Promise<never>((_, 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<typeof setTimeout>;
await Promise.race([
virtualStream.sendData(encoder.encode(logData)).then((result) => {
clearTimeout(timeoutHandle);
return result;
}),
new Promise<never>((_, 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);
};

View File

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