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);
};