From 2b3e4c93362e398afe6966698c0d7489802ef394 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Fri, 28 Aug 2026 01:11:03 +0100 Subject: [PATCH] Health: colour the swap bar by paging rate, not by fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A box that has been up for months parks cold pages in swap and never reclaims them. On my own device swap sits at 3000/3000 MB with si/so at 0, CPU 93% idle and 1.6 GB still available - nothing is wrong, but pctStatus() turned the bar red at >=90% fill, so the Health page showed a permanent alarm. That red bar is what sends people to the forum convinced their device is broken. Swap fill says how much has ever been paged out. It says nothing about whether the machine is struggling now. The rate does, and the backend already collects swap_in_pages/swap_out_pages - the line directly under the bar prints them, so the page was showing "0 · 0 KB/s" next to a red bar. Key the bar off that rate instead: 256 KB/s warns, 2 MB/s is an exception. Fill stays as the bar's length and in the MB readout, where it is informative without being a verdict. Memory and disk bars keep using fill, which is the right signal for those. The dev stub never emitted the two counters, so the rate line read 0 there regardless; it now advances them so the page can be exercised without a real device. Also bumps visual_diff_skip_build to 3084, without which this branch cannot go green. The nightly artifact prune on the CI host deleted platform/3084-*, the build FindLatestBuild("stable") resolves to, so ci-diff downloads an empty tree and fails with "no screenshots were compared" on every branch build. 3076 was the previous occurrence; the constant only matches when it equals the current latest stable, so it went inert when stable moved. This is a stopgap - ci-diff still cannot tell a pruned baseline from a screenshot regression, and DownloadBuild should fail on a missing build directory instead of returning nil. --- .drone.jsonnet | 2 +- web/platform/src/stub/api.js | 9 ++- web/platform/src/views/Health.vue | 10 ++- web/platform/tests/unit/Health.spec.js | 87 ++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 web/platform/tests/unit/Health.spec.js diff --git a/.drone.jsonnet b/.drone.jsonnet index 133e158b..f4e744d9 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -10,7 +10,7 @@ local bootstrap = '25.02'; local nginx = '1.24.0'; local python = '3.12-slim-bookworm'; local alpine = '3.21'; -local visual_diff_skip_build = '3076'; +local visual_diff_skip_build = '3084'; local build(arch, testUI) = [{ kind: 'pipeline', diff --git a/web/platform/src/stub/api.js b/web/platform/src/stub/api.js index e0ce742d..a0287a48 100644 --- a/web/platform/src/stub/api.js +++ b/web/platform/src/stub/api.js @@ -820,6 +820,8 @@ export function mock () { const stubHealth = { tick: 0, cpu: { user: 1000000, nice: 100, system: 200000, idle: 5000000, iowait: 30000, irq: 0, softirq: 10000, steal: 0 }, + swapIn: 4000000, + swapOut: 5000000, net: [ { name: 'eth0', rx_bytes: 50000000, tx_bytes: 20000000 }, { name: 'wlan0', rx_bytes: 1000000, tx_bytes: 500000 } @@ -846,6 +848,9 @@ export function mock () { d.sectors_written += Math.round(200 + 4000 * Math.random()) }) const memUsed = 1500000 + Math.round(800000 * Math.sin(stubHealth.tick / 12)) + const swapPages = Math.max(0, Math.round(400 * Math.sin(stubHealth.tick / 9))) + stubHealth.swapIn += swapPages + stubHealth.swapOut += Math.round(swapPages / 2) const data = { cpu: { ...stubHealth.cpu }, memory: { @@ -855,7 +860,9 @@ export function mock () { buffers_kb: 50000, cached_kb: 900000, swap_total_kb: 2097148, - swap_free_kb: 2097148 - 600000 - Math.round(300000 * Math.sin(stubHealth.tick / 7)) + swap_free_kb: 2097148 - 600000 - Math.round(300000 * Math.sin(stubHealth.tick / 7)), + swap_in_pages: stubHealth.swapIn, + swap_out_pages: stubHealth.swapOut }, disks: stubHealth.disks.map(d => ({ ...d })), mounts: [ diff --git a/web/platform/src/views/Health.vue b/web/platform/src/views/Health.vue index b20404db..ebc1ea87 100644 --- a/web/platform/src/views/Health.vue +++ b/web/platform/src/views/Health.vue @@ -18,7 +18,7 @@

{{ $t('health.swap') }} — {{ swapUsedMb }} / {{ swapTotalMb }} MB

- +
{{ $t('health.swapIn') }} {{ swapRate.inKBs }} · {{ $t('health.swapOut') }} {{ swapRate.outKBs }} KB/s
@@ -78,6 +78,8 @@ import axios from 'axios' const METRICS_INTERVAL_MS = 2000 const EVENTS_INTERVAL_MS = 10000 +const SWAP_ACTIVE_KBS = 256 +const SWAP_BUSY_KBS = 2048 export default { name: 'Health', @@ -109,6 +111,12 @@ export default { const t = this.metrics.memory.swap_total_kb || 0 if (!t) return 0 return ((t - (this.metrics.memory.swap_free_kb || 0)) / t) * 100 + }, + swapStatus () { + const rate = this.swapRate.inKBs + this.swapRate.outKBs + if (rate >= SWAP_BUSY_KBS) return 'exception' + if (rate >= SWAP_ACTIVE_KBS) return 'warning' + return 'success' } }, methods: { diff --git a/web/platform/tests/unit/Health.spec.js b/web/platform/tests/unit/Health.spec.js new file mode 100644 index 00000000..12cd333e --- /dev/null +++ b/web/platform/tests/unit/Health.spec.js @@ -0,0 +1,87 @@ +import { mount } from '@vue/test-utils' +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' +import flushPromises from 'flush-promises' +import Health from '../../src/views/Health.vue' + +jest.setTimeout(30000) + +function metrics (swapFreeKb, swapInPages, swapOutPages) { + return { + cpu: { user: 1000, nice: 0, system: 100, idle: 9000, iowait: 0, irq: 0, softirq: 0, steal: 0 }, + memory: { + total_kb: 8052952, + available_kb: 1605392, + free_kb: 265000, + buffers_kb: 240000, + cached_kb: 2054000, + swap_total_kb: 3073016, + swap_free_kb: swapFreeKb, + swap_in_pages: swapInPages, + swap_out_pages: swapOutPages + }, + disks: [], + mounts: [], + net: [] + } +} + +async function mountHealth (samples) { + const mock = new MockAdapter(axios) + let index = 0 + mock.onGet('/rest/health/metrics').reply(() => { + const sample = samples[Math.min(index, samples.length - 1)] + index++ + return [200, { success: true, data: sample }] + }) + mock.onGet(/\/rest\/health\/events/).reply(200, { success: true, data: [] }) + const wrapper = mount(Health, { attachTo: document.body }) + await flushPromises() + for (let i = 0; i < samples.length; i++) { + wrapper.vm.fetchMetrics() + await flushPromises() + } + return { wrapper, mock } +} + +test('full but idle swap is not flagged', async () => { + const { wrapper, mock } = await mountHealth([ + metrics(0, 1000000, 2000000), + metrics(0, 1000000, 2000000) + ]) + expect(Math.round(wrapper.vm.swapPct)).toBe(100) + expect(wrapper.vm.swapRate).toEqual({ inKBs: 0, outKBs: 0 }) + expect(wrapper.vm.swapStatus).toBe('success') + wrapper.unmount() + mock.restore() +}) + +test('sustained paging is flagged even when swap is mostly free', async () => { + const { wrapper, mock } = await mountHealth([ + metrics(3000000, 1000000, 2000000), + metrics(3000000, 1002000, 2002000) + ]) + expect(wrapper.vm.swapPct).toBeLessThan(10) + expect(wrapper.vm.swapStatus).toBe('exception') + wrapper.unmount() + mock.restore() +}) + +test('light paging warns', async () => { + const { wrapper, mock } = await mountHealth([ + metrics(1500000, 1000000, 2000000), + metrics(1500000, 1000200, 2000000) + ]) + expect(wrapper.vm.swapStatus).toBe('warning') + wrapper.unmount() + mock.restore() +}) + +test('memory and disk bars still key off fill', async () => { + const { wrapper, mock } = await mountHealth([metrics(0, 0, 0)]) + expect(wrapper.vm.pctStatus(95)).toBe('exception') + expect(wrapper.vm.pctStatus(80)).toBe('warning') + expect(wrapper.vm.pctStatus(10)).toBe('success') + wrapper.unmount() + mock.restore() +})