Files
ITWorx-Pulse-Public/tools/wallboard-soak-analysis.mjs
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

90 lines
5.4 KiB
JavaScript

export function percentile(values, fraction) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))];
}
export function median(values) {
return percentile(values, 0.5);
}
export function linearSlopePerHour(samples, field) {
if (samples.length < 2) return 0;
const xs = samples.map((sample) => sample.elapsedMs / 3_600_000);
const ys = samples.map((sample) => sample[field]);
const meanX = xs.reduce((sum, value) => sum + value, 0) / xs.length;
const meanY = ys.reduce((sum, value) => sum + value, 0) / ys.length;
const denominator = xs.reduce((sum, value) => sum + ((value - meanX) ** 2), 0);
if (denominator === 0) return 0;
return xs.reduce((sum, value, index) => sum + ((value - meanX) * (ys[index] - meanY)), 0) / denominator;
}
export function buildSoakSummary({ samples, reconnects, startedAt, completedAt, durationHours, sampleSeconds, counters, requireWallboardTitle = false }) {
if (samples.length === 0) throw new Error('at least one wallboard sample is required');
const hourly = samples.filter((sample) => sample.forcedGC);
if (hourly.length === 0) throw new Error('at least one forced-GC sample is required');
const quarterSize = Math.max(1, Math.ceil(hourly.length / 4));
const firstQuarter = hourly.slice(0, quarterSize).map((sample) => sample.usedHeapBytes);
const lastQuarter = hourly.slice(-quarterSize).map((sample) => sample.usedHeapBytes);
const retainedGrowthBytes = median(lastQuarter) - median(firstQuarter);
const heapSlopeBytesPerHour = linearSlopePerHour(hourly, 'usedHeapBytes');
const maxAllowedGrowthBytes = Math.max(16 * 1024 * 1024, median(firstQuarter) * 0.20);
const elapsedHours = (completedAt.getTime() - startedAt.getTime()) / 3_600_000;
const expectedSampleCount = durationHours * 3_600 / sampleSeconds;
const minimumSampleCount = Math.floor(Math.min(expectedSampleCount * 0.99, Math.max(1, expectedSampleCount - 2)));
const sampleGapsSeconds = samples.slice(1).map((sample, index) => (sample.elapsedMs - samples[index].elapsedMs) / 1_000);
const maxSampleGapSeconds = Math.max(0, ...sampleGapsSeconds);
const maxAllowedSampleGapSeconds = sampleSeconds + 15;
const summary = {
status: 'pass',
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
elapsedHours,
sampleCount: samples.length,
forcedGCSampleCount: hourly.length,
expectedSampleCount,
minimumSampleCount,
maxSampleGapSeconds,
maxAllowedSampleGapSeconds,
reconnectCount: reconnects.length,
reconnectFailures: reconnects.filter((item) => !item.recovered).length,
reconnectP95Ms: percentile(reconnects.map((item) => item.durationMs), 0.95),
retainedGrowthBytes,
heapSlopeBytesPerHour,
maxAllowedGrowthBytes,
fpsP05: percentile(samples.map((sample) => sample.fps), 0.05),
frameP95WorstMs: Math.max(...samples.map((sample) => sample.frameP95Ms)),
longestTaskMs: Math.max(...samples.map((sample) => sample.longestTaskMs)),
maxActiveSockets: counters.maxActiveSockets,
openedSockets: counters.openedSockets,
closedSockets: counters.closedSockets,
websocketErrors: counters.websocketErrors ?? 0,
socketChurnBudget: reconnects.length + 4,
pageErrors: counters.pageErrors,
apiFailures: counters.apiFailures,
maxDOMNodes: Math.max(...samples.map((sample) => sample.nodes)),
horizontalOverflowSamples: samples.filter((sample) => sample.bodyWidth > sample.viewportWidth).length,
verticalOverflowSamples: samples.filter((sample) => sample.bodyHeight != null && sample.viewportHeight != null && sample.bodyHeight > sample.viewportHeight).length,
invalidWallboardSamples: samples.filter((sample) => {
if (sample.wallboardTitle == null || sample.activeDashboard == null) return requireWallboardTitle;
return sample.wallboardTitle !== 'Operationeel wallboard' || !/^Wallboard soak [12]$/.test(sample.activeDashboard);
}).length,
};
const failures = [];
if (summary.elapsedHours < durationHours) failures.push('elapsed duration shorter than requested');
if (summary.sampleCount < minimumSampleCount || maxSampleGapSeconds > maxAllowedSampleGapSeconds) failures.push('sample coverage or continuity fell below the uninterrupted-run budget');
if (retainedGrowthBytes > maxAllowedGrowthBytes || heapSlopeBytesPerHour > 1024 * 1024) failures.push('retained heap growth exceeded the bounded budget');
if (summary.reconnectFailures > 0 || summary.reconnectP95Ms > 10_000) failures.push('WebSocket reconnect exceeded 10 seconds');
if (summary.frameP95WorstMs > 50 || summary.fpsP05 < 30 || summary.longestTaskMs > 250) failures.push('visible-jank budget exceeded');
if (summary.maxActiveSockets > 2) failures.push('active WebSocket count was unbounded');
if (summary.openedSockets > summary.socketChurnBudget) failures.push('WebSocket lifecycle churn exceeded the bounded budget');
if (summary.websocketErrors > 0 || summary.pageErrors > 0 || summary.apiFailures > 0) failures.push('runtime browser, API or WebSocket failures occurred');
if (summary.horizontalOverflowSamples > 0 || summary.verticalOverflowSamples > 0) failures.push('wallboard overflowed the viewport');
if (summary.invalidWallboardSamples > 0) failures.push('wallboard content disappeared during the soak');
if (failures.length > 0) {
summary.status = 'fail';
summary.failures = failures;
}
return summary;
}