From a2abedb90dfbc10f3d3c3da8b01da750d3a9ef4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 07:55:57 +0000 Subject: [PATCH 01/10] Initial plan From b1d6af568b92aafbebbd8eec6d64a0e3ea854b8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:01:07 +0000 Subject: [PATCH 02/10] Add GitHub Actions workflow for benchmark load tests Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 129 ++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/benchmark.yml diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..cc63ed02 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Benchmark Load Test + +on: + pull_request: + paths: + - 'websocket_server/**' + - 'tools/benchmarks/**' + - '.github/workflows/benchmark.yml' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + benchmark: + runs-on: ubuntu-latest + name: Run Load Test + + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^20' + fallbackNpm: '^10' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Install dependencies + env: + CYPRESS_INSTALL_BINARY: 0 + PUPPETEER_SKIP_DOWNLOAD: true + run: npm ci + + - name: Run benchmark load test + id: benchmark + run: | + # Run benchmark with smaller load for CI + export TLS=false + export JWT_SECRET_KEY=benchmark-secret + export NEXTCLOUD_URL=http://localhost + export LOAD_TEST_CONCURRENCY=50,100 + export LOAD_TEST_DURATION=45 + export NODE_OPTIONS=--max-old-space-size=8192 + + # Run the benchmark and capture output + node tools/benchmarks/runBenchmarks.mjs > benchmark_output.txt 2>&1 + + # Extract the JSON results from the output + # The script outputs JSON after the "=== Benchmark Summary ===" line + sed -n '/=== Benchmark Summary ===/,$ p' benchmark_output.txt | tail -n +2 > results.json + + # Display the full output + cat benchmark_output.txt + + # Store results for next step + echo "RESULTS<> $GITHUB_OUTPUT + cat results.json >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Display benchmark results + run: | + echo "## Benchmark Results" + cat results.json | jq '.' + + - name: Comment PR with results + if: github.event_name == 'pull_request' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const fs = require('fs'); + const results = JSON.parse(fs.readFileSync('results.json', 'utf8')); + + // Format the results as a markdown table + let comment = '## 🚀 Benchmark Load Test Results\n\n'; + comment += 'These results show the performance characteristics of the websocket server under load.\n\n'; + + // Create a table for each concurrency level + comment += '| Concurrent Users | Avg CPU | Peak CPU | Avg Memory (MB) | Peak Memory (MB) | Dropped Connections |\n'; + comment += '|------------------|---------|----------|-----------------|------------------|--------------------|\n'; + + for (const result of results) { + const { concurrency, cpu, memory, loadSummary } = result; + comment += `| ${concurrency} | ${cpu.average.toFixed(2)}% | ${cpu.peak.toFixed(2)}% | ${memory.averageRssMb.toFixed(2)} | ${memory.peakRssMb.toFixed(2)} | ${loadSummary.droppedConnections} |\n`; + } + + comment += '\n### Details\n\n'; + for (const result of results) { + const { concurrency, loadSummary } = result; + comment += `
\n📊 ${concurrency} concurrent users\n\n`; + comment += '```json\n'; + comment += JSON.stringify(result, null, 2); + comment += '\n```\n\n'; + comment += '
\n\n'; + } + + comment += '\n---\n'; + comment += '*Note: These benchmarks run with reduced load (50, 100 users) for CI efficiency. '; + comment += 'For full benchmarks, see the [README](https://github.com/nextcloud/whiteboard#benchmarking--capacity-planning).*'; + + // Post the comment + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + + - name: Upload benchmark results + uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + if: always() + with: + name: benchmark-results + path: | + results.json + benchmark_output.txt From 13dacb2b188af8768375c10fb8d79f85926503ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:03:27 +0000 Subject: [PATCH 03/10] Add error handling and validation to benchmark workflow Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index cc63ed02..ad252002 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -63,13 +63,15 @@ jobs: # The script outputs JSON after the "=== Benchmark Summary ===" line sed -n '/=== Benchmark Summary ===/,$ p' benchmark_output.txt | tail -n +2 > results.json + # Validate JSON format + if ! jq '.' results.json > /dev/null 2>&1; then + echo "Error: Failed to extract valid JSON from benchmark output" + cat benchmark_output.txt + exit 1 + fi + # Display the full output cat benchmark_output.txt - - # Store results for next step - echo "RESULTS<> $GITHUB_OUTPUT - cat results.json >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - name: Display benchmark results run: | @@ -82,7 +84,22 @@ jobs: with: script: | const fs = require('fs'); - const results = JSON.parse(fs.readFileSync('results.json', 'utf8')); + + // Read and parse results with error handling + let results; + try { + const data = fs.readFileSync('results.json', 'utf8'); + results = JSON.parse(data); + } catch (error) { + console.error('Failed to read or parse results.json:', error); + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '## ❌ Benchmark Load Test Failed\n\nFailed to parse benchmark results. Please check the workflow logs for details.' + }); + throw error; + } // Format the results as a markdown table let comment = '## 🚀 Benchmark Load Test Results\n\n'; @@ -109,7 +126,7 @@ jobs: comment += '\n---\n'; comment += '*Note: These benchmarks run with reduced load (50, 100 users) for CI efficiency. '; - comment += 'For full benchmarks, see the [README](https://github.com/nextcloud/whiteboard#benchmarking--capacity-planning).*'; + comment += `For full benchmarks, see the [README](https://github.com/${context.repo.owner}/${context.repo.repo}#benchmarking--capacity-planning).*`; // Post the comment github.rest.issues.createComment({ From 86bb6cbeb3100c20e2c74eece86c598edeeee5a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:05:05 +0000 Subject: [PATCH 04/10] Replace jq with Node.js for JSON validation and formatting Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index ad252002..d5f13aaf 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -63,8 +63,8 @@ jobs: # The script outputs JSON after the "=== Benchmark Summary ===" line sed -n '/=== Benchmark Summary ===/,$ p' benchmark_output.txt | tail -n +2 > results.json - # Validate JSON format - if ! jq '.' results.json > /dev/null 2>&1; then + # Validate JSON format using Node.js + if ! node -e "JSON.parse(require('fs').readFileSync('results.json', 'utf8'))" 2>&1; then echo "Error: Failed to extract valid JSON from benchmark output" cat benchmark_output.txt exit 1 @@ -76,7 +76,7 @@ jobs: - name: Display benchmark results run: | echo "## Benchmark Results" - cat results.json | jq '.' + node -pe "JSON.stringify(JSON.parse(require('fs').readFileSync('results.json', 'utf8')), null, 2)" - name: Comment PR with results if: github.event_name == 'pull_request' From edd2844a389be907d3a0e84decd135a1ec8d6568 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:06:27 +0000 Subject: [PATCH 05/10] Fix JSON validation and add await to async API call Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index d5f13aaf..94c6337b 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -64,7 +64,7 @@ jobs: sed -n '/=== Benchmark Summary ===/,$ p' benchmark_output.txt | tail -n +2 > results.json # Validate JSON format using Node.js - if ! node -e "JSON.parse(require('fs').readFileSync('results.json', 'utf8'))" 2>&1; then + if ! node -e "JSON.parse(require('fs').readFileSync('results.json', 'utf8'))" > /dev/null 2>&1; then echo "Error: Failed to extract valid JSON from benchmark output" cat benchmark_output.txt exit 1 @@ -129,7 +129,7 @@ jobs: comment += `For full benchmarks, see the [README](https://github.com/${context.repo.owner}/${context.repo.repo}#benchmarking--capacity-planning).*`; // Post the comment - github.rest.issues.createComment({ + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, From fbc3b8d01780a682f8c53ab001a68cd32642ed42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:07:57 +0000 Subject: [PATCH 06/10] Clean up unused variable and fix stderr redirect Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 94c6337b..c16a9557 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -64,7 +64,7 @@ jobs: sed -n '/=== Benchmark Summary ===/,$ p' benchmark_output.txt | tail -n +2 > results.json # Validate JSON format using Node.js - if ! node -e "JSON.parse(require('fs').readFileSync('results.json', 'utf8'))" > /dev/null 2>&1; then + if ! node -e "JSON.parse(require('fs').readFileSync('results.json', 'utf8'))" 2>/dev/null; then echo "Error: Failed to extract valid JSON from benchmark output" cat benchmark_output.txt exit 1 @@ -116,7 +116,7 @@ jobs: comment += '\n### Details\n\n'; for (const result of results) { - const { concurrency, loadSummary } = result; + const { concurrency } = result; comment += `
\n📊 ${concurrency} concurrent users\n\n`; comment += '```json\n'; comment += JSON.stringify(result, null, 2); From a0423095d7f03f10c786b13781cecd65f002b3b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:26:56 +0000 Subject: [PATCH 07/10] Add baseline comparison to benchmark results Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 51 +++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index c16a9557..e05ea4f0 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -85,7 +85,7 @@ jobs: script: | const fs = require('fs'); - // Read and parse results with error handling + // Read and parse current results with error handling let results; try { const data = fs.readFileSync('results.json', 'utf8'); @@ -101,9 +101,46 @@ jobs: throw error; } + // Read baseline results + let baseline = null; + try { + const baselineData = fs.readFileSync('tools/benchmarks/results.json', 'utf8'); + const baselineJson = JSON.parse(baselineData); + if (baselineJson.scenarios) { + baseline = {}; + for (const scenario of baselineJson.scenarios) { + baseline[scenario.concurrency] = scenario; + } + } + } catch (error) { + console.log('No baseline found or error reading baseline:', error.message); + } + + // Helper function to format diff + const formatDiff = (current, base, decimals = 2) => { + if (base === undefined || base === null) return current.toFixed(decimals); + const diff = current - base; + const sign = diff > 0 ? '+' : ''; + const color = diff > 0 ? '🔴' : (diff < 0 ? '🟢' : '⚪'); + return `${current.toFixed(decimals)} (${color}${sign}${diff.toFixed(decimals)})`; + }; + + const formatPercent = (current, base, decimals = 2) => { + if (base === undefined || base === null) return `${current.toFixed(decimals)}%`; + const diff = current - base; + const sign = diff > 0 ? '+' : ''; + const color = diff > 0 ? '🔴' : (diff < 0 ? '🟢' : '⚪'); + return `${current.toFixed(decimals)}% (${color}${sign}${diff.toFixed(decimals)}%)`; + }; + // Format the results as a markdown table let comment = '## 🚀 Benchmark Load Test Results\n\n'; - comment += 'These results show the performance characteristics of the websocket server under load.\n\n'; + if (baseline) { + comment += 'Comparison with baseline from `tools/benchmarks/results.json`.\n'; + comment += 'Legend: 🟢 improvement, 🔴 regression, ⚪ no change\n\n'; + } else { + comment += 'These results show the performance characteristics of the websocket server under load.\n\n'; + } // Create a table for each concurrency level comment += '| Concurrent Users | Avg CPU | Peak CPU | Avg Memory (MB) | Peak Memory (MB) | Dropped Connections |\n'; @@ -111,7 +148,15 @@ jobs: for (const result of results) { const { concurrency, cpu, memory, loadSummary } = result; - comment += `| ${concurrency} | ${cpu.average.toFixed(2)}% | ${cpu.peak.toFixed(2)}% | ${memory.averageRssMb.toFixed(2)} | ${memory.peakRssMb.toFixed(2)} | ${loadSummary.droppedConnections} |\n`; + const base = baseline ? baseline[concurrency] : null; + + const avgCpu = base ? formatPercent(cpu.average, base.cpu.average) : `${cpu.average.toFixed(2)}%`; + const peakCpu = base ? formatPercent(cpu.peak, base.cpu.peak) : `${cpu.peak.toFixed(2)}%`; + const avgMem = base ? formatDiff(memory.averageRssMb, base.memory.averageRssMb) : memory.averageRssMb.toFixed(2); + const peakMem = base ? formatDiff(memory.peakRssMb, base.memory.peakRssMb) : memory.peakRssMb.toFixed(2); + const dropped = base ? formatDiff(loadSummary.droppedConnections, base.droppedConnections, 0) : loadSummary.droppedConnections; + + comment += `| ${concurrency} | ${avgCpu} | ${peakCpu} | ${avgMem} | ${peakMem} | ${dropped} |\n`; } comment += '\n### Details\n\n'; From b0bcb8240cf4cb638819b02183d3ff92144b6f00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:29:34 +0000 Subject: [PATCH 08/10] Fix baseline property access for droppedConnections Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e05ea4f0..b129f290 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -154,7 +154,9 @@ jobs: const peakCpu = base ? formatPercent(cpu.peak, base.cpu.peak) : `${cpu.peak.toFixed(2)}%`; const avgMem = base ? formatDiff(memory.averageRssMb, base.memory.averageRssMb) : memory.averageRssMb.toFixed(2); const peakMem = base ? formatDiff(memory.peakRssMb, base.memory.peakRssMb) : memory.peakRssMb.toFixed(2); - const dropped = base ? formatDiff(loadSummary.droppedConnections, base.droppedConnections, 0) : loadSummary.droppedConnections; + // Note: baseline has droppedConnections at top level, not in loadSummary + const baseDropped = base ? base.droppedConnections : null; + const dropped = base ? formatDiff(loadSummary.droppedConnections, baseDropped, 0) : loadSummary.droppedConnections; comment += `| ${concurrency} | ${avgCpu} | ${peakCpu} | ${avgMem} | ${peakMem} | ${dropped} |\n`; } From de0339834c082a37b61a40d5c0a70b25afe493ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:41:43 +0000 Subject: [PATCH 09/10] Add network and message metrics to benchmark results table Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index b129f290..b73711e2 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -116,6 +116,11 @@ jobs: console.log('No baseline found or error reading baseline:', error.message); } + // Helper function to format numbers with thousand separators + const formatNumber = (num) => { + return Math.round(num).toLocaleString('en-US'); + }; + // Helper function to format diff const formatDiff = (current, base, decimals = 2) => { if (base === undefined || base === null) return current.toFixed(decimals); @@ -125,6 +130,14 @@ jobs: return `${current.toFixed(decimals)} (${color}${sign}${diff.toFixed(decimals)})`; }; + const formatDiffInt = (current, base) => { + if (base === undefined || base === null) return formatNumber(current); + const diff = current - base; + const sign = diff > 0 ? '+' : ''; + const color = diff > 0 ? '🔴' : (diff < 0 ? '🟢' : '⚪'); + return `${formatNumber(current)} (${color}${sign}${formatNumber(diff)})`; + }; + const formatPercent = (current, base, decimals = 2) => { if (base === undefined || base === null) return `${current.toFixed(decimals)}%`; const diff = current - base; @@ -143,8 +156,8 @@ jobs: } // Create a table for each concurrency level - comment += '| Concurrent Users | Avg CPU | Peak CPU | Avg Memory (MB) | Peak Memory (MB) | Dropped Connections |\n'; - comment += '|------------------|---------|----------|-----------------|------------------|--------------------|\n'; + comment += '| Concurrent Users | Avg CPU | Peak CPU | Avg Memory (MB) | Peak Memory (MB) | Bytes Sent | Bytes Received | Messages Sent | Messages Received | Dropped Connections |\n'; + comment += '|------------------|---------|----------|-----------------|------------------|------------|----------------|---------------|-------------------|--------------------|\n'; for (const result of results) { const { concurrency, cpu, memory, loadSummary } = result; @@ -154,11 +167,19 @@ jobs: const peakCpu = base ? formatPercent(cpu.peak, base.cpu.peak) : `${cpu.peak.toFixed(2)}%`; const avgMem = base ? formatDiff(memory.averageRssMb, base.memory.averageRssMb) : memory.averageRssMb.toFixed(2); const peakMem = base ? formatDiff(memory.peakRssMb, base.memory.peakRssMb) : memory.peakRssMb.toFixed(2); + + // Network/message metrics - use formatDiffInt for large numbers with thousand separators + const bytesSent = base ? formatDiffInt(loadSummary.bytesSent, base.network?.ingressBytes) : formatNumber(loadSummary.bytesSent); + const bytesReceived = base ? formatDiffInt(loadSummary.bytesReceived, base.network?.egressBytes) : formatNumber(loadSummary.bytesReceived); + // Messages are new metrics, show without diff if baseline doesn't have them + const messagesSent = formatNumber(loadSummary.messagesSent); + const messagesReceived = formatNumber(loadSummary.messagesReceived); + // Note: baseline has droppedConnections at top level, not in loadSummary const baseDropped = base ? base.droppedConnections : null; const dropped = base ? formatDiff(loadSummary.droppedConnections, baseDropped, 0) : loadSummary.droppedConnections; - comment += `| ${concurrency} | ${avgCpu} | ${peakCpu} | ${avgMem} | ${peakMem} | ${dropped} |\n`; + comment += `| ${concurrency} | ${avgCpu} | ${peakCpu} | ${avgMem} | ${peakMem} | ${bytesSent} | ${bytesReceived} | ${messagesSent} | ${messagesReceived} | ${dropped} |\n`; } comment += '\n### Details\n\n'; From 6bd5370d8cdfe112ad40a1e02daf844e899e7f65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:43:43 +0000 Subject: [PATCH 10/10] Add clarifying comments for network byte mapping Co-authored-by: juliusknorr <3404133+juliusknorr@users.noreply.github.com> --- .github/workflows/benchmark.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index b73711e2..8baec310 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -169,6 +169,9 @@ jobs: const peakMem = base ? formatDiff(memory.peakRssMb, base.memory.peakRssMb) : memory.peakRssMb.toFixed(2); // Network/message metrics - use formatDiffInt for large numbers with thousand separators + // Note: baseline stores network bytes from server perspective (ingress=from clients, egress=to clients) + // Current results are from client perspective (sent=to server, received=from server) + // So: bytesSent maps to ingressBytes, bytesReceived maps to egressBytes const bytesSent = base ? formatDiffInt(loadSummary.bytesSent, base.network?.ingressBytes) : formatNumber(loadSummary.bytesSent); const bytesReceived = base ? formatDiffInt(loadSummary.bytesReceived, base.network?.egressBytes) : formatNumber(loadSummary.bytesReceived); // Messages are new metrics, show without diff if baseline doesn't have them