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