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() +})