From 104759c3acf3759b023ca37aabd23be78e8050cc Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 18:56:35 +0200 Subject: [PATCH 01/20] feat(stats): clients for the three statistics routes --- src/components/Stats/__fixtures__/contract.js | 112 ++++++++++++++++++ src/service/stats.js | 59 +++++++++ src/service/stats.spec.js | 84 +++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 src/components/Stats/__fixtures__/contract.js create mode 100644 src/service/stats.js create mode 100644 src/service/stats.spec.js diff --git a/src/components/Stats/__fixtures__/contract.js b/src/components/Stats/__fixtures__/contract.js new file mode 100644 index 00000000..1164d06b --- /dev/null +++ b/src/components/Stats/__fixtures__/contract.js @@ -0,0 +1,112 @@ +// Contract-shaped builders (contract sections 1-2) for every Stats spec and the stub. +export const HOSTILE = '' + +export function anItem(over = {}) { + return { + id: 'app:jellyfin:web:1', + name: 'Jellyfin', + kind: 'app-container', + group: 'Conteneurs', + app: 'Jellyfin', + state: 'running', + started_at: '2026-10-19T08:00:00Z', + detail: 'jellyfin/jellyfin:10.9', + cpu_percent: 12.5, + memory_used: 268435456, + memory_limit: 1073741824, + alerts: 0, + ...over, + } +} + +// [[t, avg, min, max], ...]; fn(i) gives the avg (null = gap); gaps = indexes forced to a gap. +export function makePoints({ from = 1760000000, step = 5, count = 180, fn = i => (i % 50) + 1, gaps = [] } = {}) { + return Array.from({ length: count }, (_, i) => { + const t = from + i * step + const v = gaps.includes(i) ? null : fn(i) + return v === null || v === undefined ? [t, null, null, null] : [t, v, v, v] + }) +} + +// `live`: 5-s steps over 15 minutes. +const LIVE = { from: 1760000000, step: 5, count: 180 } + +// key is 'chart/name' or 'chart/name:instance' (instance = after the first ':'). +function s(key, unit, fn) { + const slash = key.indexOf('/') + return { chart: key.slice(0, slash), name: key.slice(slash + 1), unit, points: makePoints({ ...LIVE, fn }) } +} + +export const CONTAINER_SERIES = [ + s('cpu/user', 'percent'), + s('cpu/system', 'percent'), + s('cpu/iowait', 'percent'), + s('cpu/throttled', 'percent'), + s('memory/rss', 'bytes'), + s('memory/cache', 'bytes'), + s('memory/swap', 'bytes'), + s('memory/limit', 'bytes', () => 1073741824), + s('network/rx:eth0', 'bytes_per_second'), + s('network/tx:eth0', 'bytes_per_second'), + s('network/dropped:eth0', 'packets_per_second'), + s('disk/read', 'bytes_per_second'), + s('disk/write', 'bytes_per_second'), + s('disk/iops_read', 'ops_per_second'), + s('disk/iops_write', 'ops_per_second'), + s('pids/count', 'count'), + s('pids/fds', 'count'), + s('restarts/count', 'count', () => 0), + s('health/ok', 'flag', () => 1), +] + +export const VM_SERIES = [ + s('cpu/total', 'percent'), + s('cpu/vcpus', 'count', () => 2), + s('memory/assigned', 'bytes', () => 2147483648), + s('memory/used', 'bytes'), + s('network/rx:52:54:00:aa:bb:cc', 'bytes_per_second'), + s('network/tx:52:54:00:aa:bb:cc', 'bytes_per_second'), + s('disk/read:vda', 'bytes_per_second'), + s('disk/write:vda', 'bytes_per_second'), + s('disk/iops_read:vda', 'ops_per_second'), + s('disk/iops_write:vda', 'ops_per_second'), + s('health/ok', 'flag', () => 1), +] + +export const HOST_SERIES = [ + s('cpu/total', 'percent'), + s('cpu/vcpus', 'count', () => 4), + s('memory/rss', 'bytes'), + s('memory/limit', 'bytes', () => 8589934592), + s('network/rx:eth0', 'bytes_per_second'), + s('network/tx:eth0', 'bytes_per_second'), + s('disk/read', 'bytes_per_second'), + s('disk/write', 'bytes_per_second'), + s('fs/free_percent:/DATA', 'percent', () => 60), + s('fs/free_percent:/', 'percent', () => 35), +] + +export function aSeries(over = {}) { + return { + id: 'app:jellyfin:web:1', + range: 'live', + step_seconds: LIVE.step, + from: LIVE.from, + to: LIVE.from + LIVE.step * (LIVE.count - 1), + scale: { cpu: 'percent_of_one_core', cpu_allocation: 2, memory_limit: 1073741824 }, + series: CONTAINER_SERIES, + notes: [], + ...over, + } +} + +export function anAlerts(over = {}) { + return { + rules: [ + { key: 'cpu_high', enabled: true, threshold: 90, minutes: 10 }, + { key: 'mem_high', enabled: true, threshold: 90, minutes: 5 }, + ], + active: [], + ...over, + } +} diff --git a/src/service/stats.js b/src/service/stats.js new file mode 100644 index 00000000..40a4f5b6 --- /dev/null +++ b/src/service/stats.js @@ -0,0 +1,59 @@ +// The statistics clients: one per service prefix, same four calls. +import { isDots } from '@/components/VirtualMachines/vms' +import { api } from '@/service/service.js' + +export const STATS_SOURCES = ['app_management', 'virt_management', 'core'] + +const RANGES = ['live', '15m', '1h', '24h', '7d'] + +// api.get/put add /v1 themselves, so the core path has no prefix here. +const PREFIX = { + app_management: '/v2/app_management/stats', + virt_management: '/v2/virt_management/stats', + core: '/sys/stats', +} + +function base(source) { + if (!STATS_SOURCES.includes(source)) { + throw new Error('unknown source') + } + return PREFIX[source] +} + +// An id comes from a service or the bus: it must stay one segment of the route. +function seg(id) { + if (typeof id !== 'string' || id === '' || isDots(id)) { + throw new Error('not one segment of a route') + } + return encodeURIComponent(id) +} + +const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v) + +// The core wraps in {success, message, data}; the two v2 services answer bare. +function unwrap(source, res) { + const body = source === 'core' ? res?.data?.data : res?.data + return isObject(body) ? body : null +} + +export const statsApi = { + list(source) { + const url = base(source) + return api.get(url).then(res => unwrap(source, res)) + }, + series(source, id, range) { + const url = `${base(source)}/${seg(id)}` + if (!RANGES.includes(range)) { + throw new Error('unknown range') + } + return api.get(url, { range }).then(res => unwrap(source, res)) + }, + alerts(source, id) { + const url = `${base(source)}/${seg(id)}/alerts` + return api.get(url).then(res => unwrap(source, res)) + }, + setAlerts(source, id, rules) { + const url = `${base(source)}/${seg(id)}/alerts` + return api.put(url, { rules }).then(res => unwrap(source, res)) + }, +} diff --git a/src/service/stats.spec.js b/src/service/stats.spec.js new file mode 100644 index 00000000..cd5e1d18 --- /dev/null +++ b/src/service/stats.spec.js @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { STATS_SOURCES, statsApi } from './stats.js' + +const { get, put } = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() })) +vi.mock('@/service/service.js', () => ({ api: { get, put } })) + +beforeEach(() => { + get.mockReset().mockResolvedValue({ data: {} }) + put.mockReset().mockResolvedValue({ data: {} }) +}) + +describe('routes', () => { + it('lists the three sources', () => { + expect(STATS_SOURCES).toEqual(['app_management', 'virt_management', 'core']) + }) + + it('list calls the route of each source', async () => { + await statsApi.list('core') + expect(get).toHaveBeenLastCalledWith('/sys/stats') + await statsApi.list('app_management') + expect(get).toHaveBeenLastCalledWith('/v2/app_management/stats') + await statsApi.list('virt_management') + expect(get).toHaveBeenLastCalledWith('/v2/virt_management/stats') + }) + + it('series encodes the id and sends the range', async () => { + await statsApi.series('app_management', 'app:jellyfin:web:1', '7d') + expect(get).toHaveBeenCalledWith('/v2/app_management/stats/app%3Ajellyfin%3Aweb%3A1', { range: '7d' }) + }) + + it('alerts and setAlerts', async () => { + await statsApi.alerts('virt_management', 'vm1') + expect(get).toHaveBeenCalledWith('/v2/virt_management/stats/vm1/alerts') + const rules = [{ key: 'cpu_high' }] + await statsApi.setAlerts('virt_management', 'vm1', rules) + expect(put).toHaveBeenCalledWith('/v2/virt_management/stats/vm1/alerts', { rules }) + }) +}) + +describe('unwrapping', () => { + it('v2 sources give the bare body', async () => { + get.mockResolvedValue({ data: { items: [1], notes: [] } }) + expect(await statsApi.list('app_management')).toEqual({ items: [1], notes: [] }) + expect(await statsApi.list('virt_management')).toEqual({ items: [1], notes: [] }) + }) + + it('core gives data.data', async () => { + get.mockResolvedValue({ data: { success: 200, message: 'ok', data: { items: [] } } }) + expect(await statsApi.list('core')).toEqual({ items: [] }) + }) + + it('core with a bare body gives null', async () => { + get.mockResolvedValue({ data: { items: [] } }) + expect(await statsApi.list('core')).toBeNull() + }) + + it('a body that is not an object gives null', async () => { + for (const body of ['x', 3, null, undefined, [1]]) { + get.mockResolvedValue({ data: body }) + expect(await statsApi.list('app_management')).toBeNull() + } + get.mockResolvedValue({ data: { data: 'x' } }) + expect(await statsApi.list('core')).toBeNull() + }) +}) + +describe('guards', () => { + it.each(['', '.', '..', 42, null])('rejects id %j', (id) => { + expect(() => statsApi.series('core', id, 'live')).toThrow('not one segment of a route') + expect(() => statsApi.alerts('core', id)).toThrow('not one segment of a route') + expect(() => statsApi.setAlerts('core', id, [])).toThrow('not one segment of a route') + }) + + it('encodes a slash as one segment', () => { + statsApi.series('core', 'a/b', 'live') + expect(get).toHaveBeenCalledWith('/sys/stats/a%2Fb', { range: 'live' }) + }) + + it('rejects an unknown range and an unknown source', () => { + expect(() => statsApi.series('core', 'x', '1y')).toThrow('unknown range') + expect(() => statsApi.list('nope')).toThrow('unknown source') + expect(() => statsApi.series('nope', 'x', 'live')).toThrow('unknown source') + }) +}) From 9efa5c3ecbc804ad23896f8858b721565621dc09 Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 19:01:38 +0200 Subject: [PATCH 02/20] feat(stats): format values and units --- src/components/Stats/statsFormat.js | 115 +++++++++++++++++++++++ src/components/Stats/statsFormat.spec.js | 85 +++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/components/Stats/statsFormat.js create mode 100644 src/components/Stats/statsFormat.spec.js diff --git a/src/components/Stats/statsFormat.js b/src/components/Stats/statsFormat.js new file mode 100644 index 00000000..1c4b55df --- /dev/null +++ b/src/components/Stats/statsFormat.js @@ -0,0 +1,115 @@ +// Pure formatting of what the services measure. A value that cannot be shown honestly is '-', never NaN, Infinity or 0. +const MAX = 1e18 +const BYTES_EN = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] +const BYTES_FR = ['o', 'Ko', 'Mo', 'Go', 'To', 'Po'] +const NNBSP = ' ' + +// Language-file keys used by formatDuration (Task 12 checks them). +export const FORMAT_KEYS = ['{days} d', '{hours} h {minutes} min', '{minutes} min', '{seconds} s'] + +const isShowable = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX +const isFrench = locale => typeof locale === 'string' && /^fr/i.test(locale) + +function numberFormat(locale, options) { + try { + return new Intl.NumberFormat(locale, options) + } catch { + return new Intl.NumberFormat('en', options) + } +} + +// One decimal below 10, none above. +function plain(value, locale) { + return numberFormat(locale, { maximumFractionDigits: value < 9.95 ? 1 : 0 }).format(value) +} + +function scaled(value, locale) { + const names = isFrench(locale) ? BYTES_FR : BYTES_EN + let n = value + let i = 0 + while (i < names.length - 1 && Math.round(n) >= 1024) { + n /= 1024 + i++ + } + return `${plain(n, locale)} ${names[i]}` +} + +export function formatValue(value, unit, locale) { + if (!isShowable(value)) { + return '-' + } + switch (unit) { + case 'bytes': + return scaled(value, locale) + case 'bytes_per_second': + return `${scaled(value, locale)}/s` + case 'percent': + return `${plain(value, locale)}${isFrench(locale) ? NNBSP : ''}%` + case 'packets_per_second': + return `${plain(value, locale)} pkt/s` + case 'ops_per_second': + return `${plain(value, locale)} ops/s` + default: // count, flag (a fixed alias of the boolean unit) and any unit we do not know + return plain(value, locale) + } +} + +export function formatDuration(seconds, t) { + if (!isShowable(seconds)) { + return '-' + } + const s = Math.floor(seconds) + if (s >= 86400) { + return t('{days} d', { days: Math.floor(s / 86400) }) + } + if (s >= 3600) { + return t('{hours} h {minutes} min', { hours: Math.floor(s / 3600), minutes: String(Math.floor((s % 3600) / 60)).padStart(2, '0') }) + } + if (s >= 60) { + return t('{minutes} min', { minutes: Math.floor(s / 60) }) + } + return t('{seconds} s', { seconds: s }) +} + +export function localeTag(i18nLocale) { + if (typeof i18nLocale !== 'string') { + return 'en' + } + const [lang, region] = i18nLocale.split(/[_-]/) + if (!/^[a-z]{2,3}$/i.test(lang || '')) { + return 'en' + } + const tag = region && /^[a-z]{2}$/i.test(region) ? `${lang.toLowerCase()}-${region.toUpperCase()}` : lang.toLowerCase() + return Intl.NumberFormat.supportedLocalesOf(tag).length > 0 ? tag : 'en' +} + +function dateFormat(locale, options) { + try { + return new Intl.DateTimeFormat(locale, options) + } catch { + return new Intl.DateTimeFormat('en', options) + } +} + +export function formatTime(epochSeconds, rangeId, locale) { + const date = typeof epochSeconds === 'number' ? new Date(epochSeconds * 1000) : new Date(Number.NaN) + if (Number.isNaN(date.getTime())) { + return '-' + } + const long = rangeId === '24h' || rangeId === '7d' + return dateFormat(locale, long ? { weekday: 'short', hour: '2-digit', minute: '2-digit' } : { hour: '2-digit', minute: '2-digit' }).format(date) +} + +export function formatDateTime(value, locale) { + let ms = Number.NaN + if (typeof value === 'string' && value !== '') { + ms = Date.parse(value) + } else if (typeof value === 'number') { + ms = value * 1000 + } + const date = new Date(ms) + if (Number.isNaN(date.getTime())) { + return '-' + } + return dateFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(date) +} diff --git a/src/components/Stats/statsFormat.spec.js b/src/components/Stats/statsFormat.spec.js new file mode 100644 index 00000000..541e9ba5 --- /dev/null +++ b/src/components/Stats/statsFormat.spec.js @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { formatDateTime, formatDuration, formatTime, formatValue, localeTag } from './statsFormat' + +const NNBSP = ' ' + +describe('formatValue', () => { + it.each([ + [0, 'bytes', 'en', '0 B'], + [1536, 'bytes', 'en', '1.5 KB'], + [1288490188, 'bytes', 'fr-FR', '1,2 Go'], + [4300000, 'bytes_per_second', 'fr-FR', '4,1 Mo/s'], + [4300000, 'bytes_per_second', 'en', '4.1 MB/s'], + [200 * 1024, 'bytes', 'en', '200 KB'], + [38.4, 'percent', 'fr-FR', `38${NNBSP}%`], + [38.4, 'percent', 'en', '38%'], + [1500, 'percent', 'en', '1,500%'], + [1, 'flag', 'en', '1'], + [0, 'flag', 'en', '0'], + [2.5, 'count', 'en', '2.5'], + [2.5, 'zzz', 'en', '2.5'], + [3, 'packets_per_second', 'en', '3 pkt/s'], + [3, 'ops_per_second', 'en', '3 ops/s'], + ])('formats %j as %s (%s)', (value, unit, locale, text) => { + expect(formatValue(value, unit, locale)).toBe(text) + }) + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -1, -0.0001, 1e300, 2e18, '12', null, undefined, {}])('gives a hyphen for %j', (value) => { + for (const unit of ['bytes', 'bytes_per_second', 'percent', 'count', 'flag', 'zzz']) { + expect(formatValue(value, unit, 'en')).toBe('-') + } + }) + + it('never writes an exponent and survives a bad locale', () => { + expect(formatValue(1e18, 'count', 'en')).not.toMatch(/e/i) + expect(formatValue(1e18, 'bytes', 'en')).not.toMatch(/e\+/i) + expect(formatValue(1, 'bytes', 'not a locale!!')).toBe('1 B') + }) +}) + +describe('formatDuration', () => { + const t = (key, p = {}) => key.replaceAll(/\{(\w+)\}/g, (_, k) => p[k]) + it.each([ + [(12 * 86400) + 5, '12 d'], + [(3 * 3600) + (5 * 60), '3 h 05 min'], + [90, '1 min'], + [7, '7 s'], + [Number.NaN, '-'], + [-5, '-'], + ['x', '-'], + ])('%j gives %s', (seconds, text) => { + expect(formatDuration(seconds, t)).toBe(text) + }) +}) + +describe('formatTime', () => { + it('gives hour and minute for short ranges and a weekday for long ones', () => { + const t0 = 1760000000 + expect(formatTime(t0, 'live', 'en')).toMatch(/\d{1,2}:\d{2}/) + expect(formatTime(t0, '7d', 'en')).toMatch(/[a-z]{3}/i) + expect(formatTime(Number.NaN, 'live', 'en')).toBe('-') + }) +}) + +describe('localeTag', () => { + it.each([ + ['fr_fr', 'fr-FR'], + ['en_us', 'en-US'], + ['zz_zz', 'en'], + ['', 'en'], + [undefined, 'en'], + ['', 'en'], + ])('%j becomes %s', (input, tag) => { + expect(localeTag(input)).toBe(tag) + }) +}) + +describe('formatDateTime', () => { + it('formats RFC 3339 and epoch seconds', () => { + expect(formatDateTime('2026-10-20T10:00:00Z', 'en')).toContain('2026') + expect(formatDateTime(1760000000, 'en')).toContain('2025') + }) + it.each(['garbage', null, undefined, '', Number.NaN, 1e300, {}])('gives a hyphen for %j', (value) => { + expect(formatDateTime(value, 'en')).toBe('-') + }) +}) From f2bc7633dfe19ecb5c8658d5a1274f79e6a62a07 Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 19:07:16 +0200 Subject: [PATCH 03/20] feat(stats): map the statistics series to charts --- src/components/Stats/statsData.js | 431 +++++++++++++++++++++++++ src/components/Stats/statsData.spec.js | 303 +++++++++++++++++ 2 files changed, 734 insertions(+) create mode 100644 src/components/Stats/statsData.js create mode 100644 src/components/Stats/statsData.spec.js diff --git a/src/components/Stats/statsData.js b/src/components/Stats/statsData.js new file mode 100644 index 00000000..a69c1c46 --- /dev/null +++ b/src/components/Stats/statsData.js @@ -0,0 +1,431 @@ +/** + * What the Statistics window makes of what the three services send: cleaning (every value may be absent, odd or hostile), + * grouping, the chart definitions and the mapping of contract series to apexcharts series. + * + * A point that is not a finite, non-negative, sane number is a gap (null), never 0. A string from a service is plain text: + * apexcharts builds its tooltip with innerHTML, so the series NAME it receives is escaped first (`name`); `label` is for Vue text. + */ +import escape from 'lodash/escape' +import { FORMAT_KEYS, formatTime, formatValue } from './statsFormat' +import { clean } from '@/components/settings/osPlan' + +const MAX_ITEMS = 500 +const MAX_SERIES = 500 +const MAX_INSERTED = 2000 +const MAX_VALUE = 1e18 +const STATES = ['running', 'stopped', 'paused', 'crashed', 'unknown'] +const PALETTE = ['#00ab44', '#3b9cf5', '#f5a623', '#e0568a', '#9a6bf0'] +const REF_COLOR = '#8a8f98' + +export const GROUPS = [ + { id: 'containers', label: 'Containers', kinds: ['app-container'] }, + { id: 'other', label: 'Other containers', kinds: ['container'] }, + { id: 'vms', label: 'Virtual Machines', kinds: ['vm'] }, + { id: 'host', label: 'Machine', kinds: ['host'] }, +] + +const KINDS = GROUPS.flatMap(g => g.kinds) + +const own = (table, key) => typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined +const isPlain = v => v !== null && typeof v === 'object' && !Array.isArray(v) +const isNumber = v => typeof v === 'number' && Number.isFinite(v) +const saneValue = v => isNumber(v) && v >= 0 && v <= MAX_VALUE ? v : null +const nonNegative = v => isNumber(v) && v >= 0 ? v : null + +// ---------------------------------------------------------------- items + +export function normalizeItems(raw, source) { + if (!isPlain(raw) || !Array.isArray(raw.items)) { + return [] + } + const items = [] + for (const entry of raw.items.slice(0, MAX_ITEMS)) { + if (!isPlain(entry)) { + continue + } + const { id, kind } = entry + if (typeof id !== 'string' || id === '' || id.length > 300 || !KINDS.includes(kind)) { + continue + } + const started = typeof entry.started_at === 'string' ? Date.parse(entry.started_at) : Number.NaN + items.push({ + id, + source, + kind, + name: clean(entry.name, 80) || clean(id, 80), + state: STATES.includes(entry.state) ? entry.state : 'unknown', + cpu_percent: nonNegative(entry.cpu_percent), + memory_used: nonNegative(entry.memory_used), + memory_limit: nonNegative(entry.memory_limit), + alerts: nonNegative(entry.alerts) ?? 0, + started_at: Number.isNaN(started) ? null : entry.started_at, + detail: clean(entry.detail, 200) || null, + }) + } + return items +} + +export function listNotes(raw) { + if (!isPlain(raw) || !Array.isArray(raw.notes)) { + return [] + } + return raw.notes.filter(n => typeof n === 'string').slice(0, 5).map(n => clean(n, 60)) +} + +export function groupItems(items) { + return GROUPS + .map(g => ({ + id: g.id, + label: g.label, + items: items.filter(i => g.kinds.includes(i.kind)).sort((a, b) => a.name.localeCompare(b.name)), + })) + .filter(g => g.items.length > 0) +} + +// ---------------------------------------------------------------- charts + +// Every key is `chart/name`, matched exactly or with an instance (`:eth0`). +export const CHARTS = [ + { id: 'cpu', title: 'Processor', stacked: true, unit: 'percent', keys: [ + { key: 'cpu/user', label: 'User', kind: 'area', color: PALETTE[0] }, + { key: 'cpu/system', label: 'System', kind: 'area', color: PALETTE[1] }, + { key: 'cpu/iowait', label: 'Waiting for disk', kind: 'area', color: PALETTE[2] }, + { key: 'cpu/throttled', label: 'Throttled', kind: 'line', color: PALETTE[3] }, + { key: 'cpu/total', label: 'Total', kind: 'area', color: PALETTE[0] }, + { key: 'cpu/vcpus', label: 'Processor limit', kind: 'ref', color: REF_COLOR }, + ] }, + { id: 'memory', title: 'Memory', stacked: true, unit: 'bytes', keys: [ + { key: 'memory/rss', label: 'Resident', kind: 'area', color: PALETTE[0] }, + { key: 'memory/cache', label: 'Cache', kind: 'area', color: PALETTE[1] }, + { key: 'memory/swap', label: 'Swap', kind: 'area', color: PALETTE[2] }, + { key: 'memory/limit', label: 'Memory limit', kind: 'ref', color: REF_COLOR }, + { key: 'memory/assigned', label: 'Assigned', kind: 'line', color: PALETTE[3] }, + { key: 'memory/used', label: 'Used', kind: 'area', color: PALETTE[0] }, + ] }, + { id: 'network', title: 'Network', stacked: false, unit: 'bytes_per_second', keys: [ + { key: 'network/rx', label: 'Received', kind: 'area', color: PALETTE[0] }, + { key: 'network/tx', label: 'Sent', kind: 'area', color: PALETTE[1] }, + ] }, + { id: 'network_dropped', title: 'Dropped packets', stacked: false, unit: 'packets_per_second', keys: [ + { key: 'network/dropped', label: 'Dropped', kind: 'line', color: PALETTE[3] }, + ] }, + { id: 'disk', title: 'Disk', stacked: false, unit: 'bytes_per_second', keys: [ + { key: 'disk/read', label: 'Read', kind: 'area', color: PALETTE[0] }, + { key: 'disk/write', label: 'Write', kind: 'area', color: PALETTE[1] }, + ] }, + { id: 'disk_iops', title: 'Disk operations', stacked: false, unit: 'ops_per_second', keys: [ + { key: 'disk/iops_read', label: 'Read operations', kind: 'line', color: PALETTE[0] }, + { key: 'disk/iops_write', label: 'Write operations', kind: 'line', color: PALETTE[1] }, + ] }, + { id: 'fs', title: 'Free space', stacked: false, unit: 'percent', keys: [ + { key: 'fs/free_percent', label: 'Free', kind: 'line', color: PALETTE[0] }, + ] }, + { id: 'pids', title: 'Processes', stacked: false, unit: 'count', keys: [ + { key: 'pids/count', label: 'Processes', kind: 'line', color: PALETTE[0] }, + { key: 'pids/fds', label: 'Open files', kind: 'line', color: PALETTE[1] }, + ] }, + { id: 'restarts', title: 'Restarts', stacked: false, unit: 'count', keys: [ + { key: 'restarts/count', label: 'Restarts', kind: 'step', color: PALETTE[2] }, + ] }, + { id: 'health', title: 'Health', stacked: false, unit: 'flag', keys: [ + { key: 'health/ok', label: 'Healthy', kind: 'step', color: PALETTE[0] }, + ] }, +] + +const NOTE_TEXTS = { + vm_memory_used_unavailable: 'The memory used inside the virtual machine is not reported.', + no_cpu_limit: 'No processor limit is set: the cores of the machine are the reference.', + no_memory_limit: 'No memory limit is set.', + container_gone: 'This container no longer exists: only its history is shown.', + stats_capacity: 'Too many items: some of them are not measured.', +} +// The chart a note belongs to; the others are for the page. +const NOTE_CHART = { vm_memory_used_unavailable: 'memory', no_cpu_limit: 'cpu', no_memory_limit: 'memory' } + +const VCPUS_VM = 'Capacity of the virtual processors' +const VCPUS_HOST = 'Processor limit' + +export const STATS_KEYS = [...new Set([ + ...GROUPS.map(g => g.label), + ...CHARTS.flatMap(c => [c.title, ...c.keys.map(k => k.label)]), + VCPUS_VM, + VCPUS_HOST, + ...Object.values(NOTE_TEXTS), + 'Note from the service: {code}', + '{label} (max)', + '% of one core', + '{n} cores allocated', + 'no limit set', + 'on {size} allowed', + 'Processor', + 'Memory', + 'Received', + 'Sent', + 'Disk', + 'Processes', + 'Virtual CPUs', + 'No data for this period', + ...FORMAT_KEYS, +])] + +export function noteText(code, t) { + const key = own(NOTE_TEXTS, code) + return key ? t(key) : t('Note from the service: {code}', { code: clean(code, 60) }) +} + +export function cleanSeries(raw, { stepSeconds } = {}) { + const byTime = new Map() + for (const p of Array.isArray(raw) ? raw : []) { + if (!Array.isArray(p) || !isNumber(p[0])) { + continue + } + const avg = saneValue(p[1]) + byTime.set(p[0], { t: p[0], avg, min: saneValue(p[2]) ?? avg, max: saneValue(p[3]) ?? avg }) + } + const sorted = [...byTime.values()].sort((a, b) => a.t - b.t) + if (!(stepSeconds > 0) || !Number.isFinite(stepSeconds)) { + return { points: sorted } + } + const points = [] + let inserted = 0 + for (const p of sorted) { + const last = points.at(-1) + if (last && p.t - last.t > 1.5 * stepSeconds && inserted < MAX_INSERTED) { + points.push({ t: last.t + stepSeconds, avg: null, min: null, max: null }) + inserted++ + } + points.push(p) + } + return { points } +} + +const matches = (base, key) => key === base || key.startsWith(`${base}:`) + +function buildOne(def, byKey, { range, hidden, kind, t }, response) { + // the series of this chart, in the order of the definition + const found = [] + for (const k of def.keys) { + let ordinal = 0 + for (const [key, cleaned] of byKey) { + if (matches(k.key, key)) { + const instance = key.length > k.key.length ? key.slice(k.key.length + 1) : '' + const color = k.kind === 'ref' ? k.color : PALETTE[(PALETTE.indexOf(k.color) + ordinal) % PALETTE.length] + found.push({ def: k, key, instance, color, points: cleaned.points }) + ordinal++ + } + } + } + if (found.length === 0) { + return null + } + const notes = Array.isArray(response.notes) ? response.notes : [] + const alloc = response.scale?.cpu_allocation + const labelOf = (f) => { + let base = f.key === 'cpu/vcpus' ? t(kind === 'vm' ? VCPUS_VM : VCPUS_HOST) : t(f.def.label) + if (f.instance) { + base += ` · ${escape(clean(f.instance, 40))}` + } + return base + } + // a stacked chart has a gap wherever any component has one + if (def.stacked) { + const parts = found.filter(f => f.def.kind === 'area') + const bad = new Set() + const times = new Set(parts.flatMap(f => f.points.map(p => p.t))) + for (const time of times) { + if (parts.some(f => (f.points.find(p => p.t === time)?.avg ?? null) === null)) { + bad.add(time) + } + } + for (const f of parts) { + f.points = f.points.map(p => bad.has(p.t) ? { t: p.t, avg: null, min: null, max: null } : p) + } + } + const scaleRef = f => f.def.kind === 'ref' && f.key === 'cpu/vcpus' + ? { ...f, points: f.points.map(p => ({ t: p.t, avg: p.avg === null ? null : p.avg * 100, min: p.min === null ? null : p.min * 100, max: p.max === null ? null : p.max * 100 })) } + : f + const list = found.map(scaleRef) + // the processor limit of a container comes from the scale, not from a series + if (def.id === 'cpu' && !byKey.has('cpu/vcpus') && !notes.includes('no_cpu_limit') && isNumber(alloc) && alloc > 0) { + const base = found.find(f => f.def.kind !== 'ref') + if (base) { + list.push({ def: { key: 'cpu/allocation', label: 'Processor limit', kind: 'ref' }, key: 'cpu/allocation', instance: '', color: REF_COLOR, points: base.points.map(p => ({ t: p.t, avg: alloc * 100, min: alloc * 100, max: alloc * 100 })) }) + } + } + const empty = !list.some(f => f.def.kind !== 'ref' && f.points.some(p => p.avg !== null)) + const series = [] + for (const f of list) { + if (hidden.has(f.key)) { + continue + } + const label = labelOf(f) + const toData = pick => f.points.map(p => ({ x: p.t * 1000, y: p[pick] })) + series.push({ key: f.key, name: escape(label), label, color: f.color, kind: f.def.kind, points: toData('avg'), cleaned: f.points }) + if ((range === '24h' || range === '7d') && f.def.kind === 'area') { + const maxLabel = t('{label} (max)', { label }) + series.push({ key: `${f.key}#max`, name: escape(maxLabel), label: maxLabel, color: f.color, kind: 'line', points: toData('max'), cleaned: f.points.map(p => ({ ...p, avg: p.max })) }) + } + } + return { + id: def.id, + title: t(def.title), + unit: def.unit, + stacked: def.stacked, + wide: Boolean(def.wide), + empty, + series, + caption: chartCaption(def, response.scale, notes, t), + noteCodes: notes.filter(n => own(NOTE_CHART, n) === def.id).map(n => clean(n, 60)), + } +} + +export function buildCharts(response, { range = 'live', hidden = new Set(), kind = '', t = k => k } = {}) { + if (!isPlain(response) || !Array.isArray(response.series)) { + return [] + } + const byKey = new Map() + for (const s of response.series.slice(0, MAX_SERIES)) { + if (isPlain(s) && typeof s.chart === 'string' && typeof s.name === 'string') { + byKey.set(`${s.chart}/${s.name}`, cleanSeries(s.points, { stepSeconds: response.step_seconds })) + } + } + return CHARTS.map(def => buildOne(def, byKey, { range, hidden, kind, t }, response)).filter(Boolean) +} + +// ---------------------------------------------------------------- scale, options, captions + +function observedMax(chart) { + let max = Number.NEGATIVE_INFINITY + const stack = new Map() + for (const s of chart.series) { + if (s.key.endsWith('#max')) { + continue + } + for (const p of s.points) { + if (!isNumber(p.y)) { + continue + } + if (chart.stacked && s.kind === 'area') { + stack.set(p.x, (stack.get(p.x) ?? 0) + p.y) + } else { + max = Math.max(max, p.y) + } + } + } + for (const v of stack.values()) { + max = Math.max(max, v) + } + return max +} + +export function scaleMax(chart, scale) { + const values = [observedMax(chart)] + if (chart.id === 'fs') { + return 100 + } + if (chart.id === 'cpu' && isNumber(scale?.cpu_allocation)) { + values.push(scale.cpu_allocation * 100) + } + if (chart.id === 'memory' && isNumber(scale?.memory_limit)) { + values.push(scale.memory_limit) + } + const max = Math.max(...values.filter(Number.isFinite)) + return Number.isFinite(max) && max > 0 ? max * 1.1 : undefined +} + +export function chartCaption(chart, scale, notes, t, locale = 'en') { + const list = Array.isArray(notes) ? notes : [] + const parts = [] + if (chart.id === 'cpu') { + parts.push(t('% of one core')) + if (list.includes('no_cpu_limit')) { + parts.push(t('no limit set')) + } else if (isNumber(scale?.cpu_allocation) && scale.cpu_allocation > 0) { + parts.push(t('{n} cores allocated', { n: scale.cpu_allocation })) + } + } else if (chart.id === 'memory') { + if (list.includes('no_memory_limit')) { + parts.push(t('no limit set')) + } else if (isNumber(scale?.memory_limit) && scale.memory_limit > 0) { + parts.push(t('on {size} allowed', { size: formatValue(scale.memory_limit, 'bytes', locale) })) + } + } + return parts.join(' · ') +} + +export function chartOptions(chart, { group, zoom, height, locale = 'en', range = 'live', scale } = {}) { + const y = v => formatValue(v, chart.unit, locale) + const time = v => formatTime(Number(v) / 1000, range, locale) + return { + chart: { + id: `stats-${chart.id}`, + group, + type: 'line', + height, + stacked: chart.stacked, + toolbar: { show: false }, + animations: { enabled: false }, + }, + legend: { show: false }, + stroke: { curve: 'straight', width: 2 }, + dataLabels: { enabled: false }, + xaxis: { type: 'datetime', min: zoom ? zoom.min : null, max: zoom ? zoom.max : null, labels: { formatter: time } }, + yaxis: { min: 0, max: scaleMax(chart, scale), labels: { formatter: y } }, + tooltip: { shared: true, x: { formatter: time }, y: { formatter: y } }, + annotations: {}, + } +} + +// ---------------------------------------------------------------- figures and tables + +function lastFresh(series, response) { + const step = response.step_seconds + const to = response.to + const points = cleanSeries(series.points, { stepSeconds: step }).points + const p = [...points].reverse().find(q => q.avg !== null) + if (!p) { + return null + } + return isNumber(to) && isNumber(step) && p.t < to - (3 * step) ? null : p.avg +} + +export function figures(response, item, locale, t) { + const list = isPlain(response) && Array.isArray(response.series) ? response.series.filter(isPlain) : [] + const vals = base => list.filter(s => matches(base, `${s.chart}/${s.name}`)).map(s => lastFresh(s, response)).filter(v => v !== null) + const sum = (bases) => { + const all = bases.flatMap(vals) + return all.length > 0 ? all.reduce((a, b) => a + b, 0) : null + } + const vm = item?.kind === 'vm' + const memory = vm ? (sum(['memory/used']) ?? sum(['memory/assigned'])) : sum(['memory/rss']) + const cpu = sum(['cpu/total']) ?? sum(['cpu/user', 'cpu/system']) + const last = vm ? { id: 'vcpus', label: t('Virtual CPUs'), value: sum(['cpu/vcpus']), unit: 'count' } : { id: 'processes', label: t('Processes'), value: sum(['pids/count']), unit: 'count' } + return [ + { id: 'cpu', label: t('Processor'), value: cpu, unit: 'percent' }, + { id: 'memory', label: t('Memory'), value: memory, unit: 'bytes' }, + { id: 'rx', label: t('Received'), value: sum(['network/rx']), unit: 'bytes_per_second' }, + { id: 'tx', label: t('Sent'), value: sum(['network/tx']), unit: 'bytes_per_second' }, + { id: 'disk', label: t('Disk'), value: sum(['disk/read', 'disk/write']), unit: 'bytes_per_second' }, + last, + ].map(f => ({ id: f.id, label: f.label, text: f.value === null ? '-' : formatValue(f.value, f.unit, locale) })) +} + +export function tableRows(chart, locale) { + return chart.series.filter(s => !s.key.endsWith('#max')).map((s) => { + const pick = field => s.cleaned.map(p => p[field]).filter(v => v !== null) + const avgs = pick('avg') + const show = v => formatValue(v, chart.unit, locale) + if (avgs.length === 0) { + return { name: s.label, last: '-', min: '-', avg: '-', max: '-' } + } + const mins = pick('min') + const maxs = pick('max') + return { + name: s.label, + last: show(avgs.at(-1)), + min: show(Math.min(...(mins.length > 0 ? mins : avgs))), + avg: show(avgs.reduce((a, b) => a + b, 0) / avgs.length), + max: show(Math.max(...(maxs.length > 0 ? maxs : avgs))), + } + }) +} diff --git a/src/components/Stats/statsData.spec.js b/src/components/Stats/statsData.spec.js new file mode 100644 index 00000000..56d008b1 --- /dev/null +++ b/src/components/Stats/statsData.spec.js @@ -0,0 +1,303 @@ +import escape from 'lodash/escape' +import { describe, expect, it } from 'vitest' +import { CONTAINER_SERIES, HOSTILE, HOST_SERIES, VM_SERIES, aSeries, anItem } from './__fixtures__/contract' +import { GROUPS, STATS_KEYS, buildCharts, chartCaption, chartOptions, cleanSeries, figures, groupItems, listNotes, normalizeItems, noteText, scaleMax, tableRows } from './statsData' +import { clean } from '@/components/settings/osPlan' + +const t = (key, p = {}) => key.replaceAll(/\{(\w+)\}/g, (_, k) => p[k]) +// a Series response with the given lines: [chart, name, unit, points] +const resp = (lines, over = {}) => aSeries({ series: lines.map(([chart, name, unit, points]) => ({ chart, name, unit, points })), ...over }) +const pts = (values, step = 5, from = 1000) => values.map((v, i) => [from + (i * step), v, v, v]) +const find = (charts, id) => charts.find(c => c.id === id) + +describe('normalizeItems', () => { + it('keeps the contract kinds and ignores the service\'s group text', () => { + const items = normalizeItems({ items: [anItem({ kind: 'vm', group: 'ANYTHING' })] }, 'virt_management') + const groups = groupItems(items) + expect(groups.map(g => g.id)).toEqual(['vms']) + expect(items[0].source).toBe('virt_management') + }) + + it('turns hostile text into plain text', () => { + const [a] = normalizeItems({ items: [anItem({ name: `${HOSTILE}‮`, detail: HOSTILE })] }, 'core') + expect(a.name).toBe(clean(HOSTILE, 80)) + expect(a.detail).toBe(clean(HOSTILE, 200)) + expect([...a.name].every(c => c.codePointAt(0) > 31 && c.codePointAt(0) !== 0x202E)).toBe(true) + expect(normalizeItems({ items: [anItem({ name: 'x'.repeat(600), detail: 'd'.repeat(600) })] }, 'core')[0]).toMatchObject({ name: 'x'.repeat(80), detail: 'd'.repeat(200) }) + }) + + it('drops bad ids and bad kinds, bounds the count, cleans the numbers', () => { + const bad = ['', 42, 'i'.repeat(301)].map(id => anItem({ id })) + expect(normalizeItems({ items: [...bad, anItem({ kind: 'nope' })] }, 'core')).toEqual([]) + const many = Array.from({ length: 501 }, (_, i) => anItem({ id: `c${i}` })) + expect(normalizeItems({ items: many }, 'core')).toHaveLength(500) + const [x] = normalizeItems({ items: [anItem({ state: '', cpu_percent: Number.NaN, alerts: -3, memory_used: -1, started_at: 'garbage' })] }, 'core') + expect(x).toMatchObject({ state: 'unknown', cpu_percent: null, alerts: 0, memory_used: null, started_at: null }) + expect(normalizeItems({ items: [anItem({ started_at: '2026-10-19T08:00:00Z' })] }, 'core')[0].started_at).toBe('2026-10-19T08:00:00Z') + }) + + it.each([null, undefined, 'x', [], {}, { items: 'x' }])('gives [] for %j', (raw) => { + expect(normalizeItems(raw, 'core')).toEqual([]) + }) + + it('listNotes keeps at most 5 cleaned codes', () => { + expect(listNotes({ notes: Array.from({ length: 9 }, (_, i) => `n${i}`) })).toHaveLength(5) + expect(listNotes({ notes: [HOSTILE, 3] })).toEqual([clean(HOSTILE, 60)]) + expect(listNotes(null)).toEqual([]) + }) + + it('groups in order, sorts by name, omits the empty ones', () => { + const items = normalizeItems({ items: [anItem({ id: 'b', name: 'b' }), anItem({ id: 'a', name: 'a' }), anItem({ id: 'h', kind: 'host', name: 'h' })] }, 'x') + const groups = groupItems(items) + expect(groups.map(g => g.id)).toEqual(['containers', 'host']) + expect(groups[0].items.map(i => i.name)).toEqual(['a', 'b']) + expect(GROUPS.map(g => g.id)).toEqual(['containers', 'other', 'vms', 'host']) + }) +}) + +describe('cleanSeries is gap-safe', () => { + it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, 1e300, '5', undefined])('turns %j into a gap', (v) => { + const { points } = cleanSeries([[10, v, v, v]], { stepSeconds: 5 }) + expect(points).toEqual([{ t: 10, avg: null, min: null, max: null }]) + }) + + it('drops a bad timestamp, sorts, keeps the last of equal timestamps', () => { + const { points } = cleanSeries([[20, 2, 2, 2], [Number.NaN, 9, 9, 9], [10, 1, 1, 1], [20, 3, 3, 3]], { stepSeconds: 10 }) + expect(points.map(p => [p.t, p.avg])).toEqual([[10, 1], [20, 3]]) + }) + + it('falls back to avg for min and max only when avg is a number', () => { + const { points } = cleanSeries([[10, 4, 'x', null], [15, null, 3, 3]], { stepSeconds: 5 }) + expect(points[0]).toMatchObject({ avg: 4, min: 4, max: 4 }) + expect(points[1].avg).toBeNull() + }) + + it('inserts a null between points more than 1.5 steps apart, bounded', () => { + const { points } = cleanSeries([[0, 1, 1, 1], [50, 2, 2, 2]], { stepSeconds: 5 }) + expect(points.map(p => p.avg)).toEqual([1, null, 2]) + const hostile = Array.from({ length: 5000 }, (_, i) => [i * 1e9, 1, 1, 1]) + const out = cleanSeries(hostile, { stepSeconds: 5 }).points + expect(out.length - 5000).toBeLessThanOrEqual(2000) + }) +}) + +describe('buildCharts', () => { + it('draws a gap, never a zero', () => { + const charts = buildCharts(resp([['network', 'rx:eth0', 'bytes_per_second', pts([1, null, 3])]]), { t }) + expect(find(charts, 'network').series[0].points.map(p => p.y)).toEqual([1, null, 3]) + }) + + it('stack rule: a gap in one component is a gap in all of them', () => { + const charts = buildCharts(resp([ + ['cpu', 'user', 'percent', pts([1, 2, null, 4])], + ['cpu', 'system', 'percent', pts([5, 5, 5, 5])], + ]), { t }) + const cpu = find(charts, 'cpu') + for (const s of cpu.series.filter(x => x.kind !== 'ref')) { + expect(s.points.map(p => p.y)[2]).toBeNull() + } + expect(cpu.series.find(s => s.key === 'cpu/system').points.map(p => p.y)).toEqual([5, 5, null, 5]) + }) + + it('omits charts without data and marks all-null as empty', () => { + const ids = buildCharts(aSeries({ series: HOST_SERIES }), { kind: 'host', t }).map(c => c.id) + expect(ids).not.toContain('pids') + expect(ids).toContain('fs') + const charts = buildCharts(resp([['disk', 'read', 'bytes_per_second', pts([null, null])]]), { t }) + expect(find(charts, 'disk').empty).toBe(true) + expect(find(buildCharts(aSeries(), { t }), 'cpu').empty).toBe(false) + }) + + it('escapes names', () => { + const charts = buildCharts(resp([['network', 'rx:eth0', 'bytes_per_second', pts([1, 2])]]), { t }) + const s = find(charts, 'network').series[0] + expect(s.name).not.toContain('<') + expect(s.label).toContain('<img') + expect(s.name).toBe(escape(s.label)) + expect(s.name).toContain('&lt;img') + expect(s.name.startsWith('Received · ')).toBe(true) + }) + + it('escapes hostile text in names even for the first character', () => { + const charts = buildCharts(resp([['disk', 'read:x', 'bytes_per_second', pts([1])]]), { t }) + expect(JSON.stringify(find(charts, 'disk').series.map(s => s.name))).not.toMatch(//) + }) + + it('hides legend-toggled series but keeps the colour of the others', () => { + const full = find(buildCharts(aSeries(), { t }), 'cpu').series + const some = find(buildCharts(aSeries(), { t, hidden: new Set(['cpu/system']) }), 'cpu').series + expect(some.find(s => s.key === 'cpu/system')).toBeUndefined() + expect(some.find(s => s.key === 'cpu/iowait').color).toBe(full.find(s => s.key === 'cpu/iowait').color) + }) + + it('adds the max companions on 24h and 7d only', () => { + const hasMax = range => find(buildCharts(aSeries(), { t, range }), 'network').series.some(s => s.key.endsWith('#max')) + expect(hasMax('live')).toBe(false) + expect(hasMax('1h')).toBe(false) + expect(hasMax('24h')).toBe(true) + expect(hasMax('7d')).toBe(true) + const m = find(buildCharts(aSeries(), { t, range: '7d' }), 'network').series.find(s => s.key === 'network/rx:eth0#max') + expect(m.kind).toBe('line') + expect(m.label).toBe('Received · eth0 (max)') + }) + + it('draws the processor reference line', () => { + const user = ['cpu', 'user', 'percent', pts([1, 2])] + const ref = (response, kind) => find(buildCharts(response, { t, kind }), 'cpu').series.find(s => s.kind === 'ref') + const c = ref(resp([user], { scale: { cpu_allocation: 1.5, memory_limit: null }, notes: [] }), 'app-container') + expect(c.name).toBe('Processor limit') + expect(c.points.map(p => p.y)).toEqual([150, 150]) + expect(ref(resp([user], { scale: { cpu_allocation: 1.5 }, notes: ['no_cpu_limit'] }), 'app-container')).toBeUndefined() + expect(ref(resp([user], { scale: { cpu_allocation: Number.NaN }, notes: [] }), 'app-container')).toBeUndefined() + const vm = ref(resp([['cpu', 'total', 'percent', pts([1])], ['cpu', 'vcpus', 'count', pts([4])]]), 'vm') + expect(vm.name).toBe('Capacity of the virtual processors') + expect(vm.points[0].y).toBe(400) + const host = ref(resp([['cpu', 'total', 'percent', pts([1])], ['cpu', 'vcpus', 'count', pts([8])]]), 'host') + expect(host.name).toBe('Processor limit') + expect(host.points[0].y).toBe(800) + }) + + it('works on the three contract fixtures', () => { + for (const [series, kind] of [[CONTAINER_SERIES, 'app-container'], [VM_SERIES, 'vm'], [HOST_SERIES, 'host']]) { + const charts = buildCharts(aSeries({ series }), { kind, t }) + expect(charts.length).toBeGreaterThan(3) + expect(JSON.stringify(charts)).not.toMatch(/NaN|Infinity/) + } + }) + + it.each([null, 'x', {}, { series: 'x' }])('gives [] for %j', (response) => { + expect(buildCharts(response, { t })).toEqual([]) + }) +}) + +describe('chartOptions', () => { + const charts = buildCharts(aSeries(), { t }) + const scale = { cpu_allocation: 2, memory_limit: 1073741824 } + + it('shares a group and an id per chart, and formats with the formatters', () => { + const net = find(charts, 'network') + const cpu = find(charts, 'cpu') + const a = chartOptions(net, { group: 'g', locale: 'en', range: 'live', height: 200, scale }) + const b = chartOptions(cpu, { group: 'g', locale: 'en', range: 'live', height: 200, scale }) + expect(a.chart.group).toBe('g') + expect(b.chart.group).toBe('g') + expect(a.chart.id).toBe('stats-network') + expect(a.chart.stacked).toBe(false) + expect(b.chart.stacked).toBe(true) + expect(a.chart.toolbar.show).toBe(false) + expect(a.legend.show).toBe(false) + expect(a.stroke.curve).toBe('straight') + expect(a.xaxis.type).toBe('datetime') + expect(a.yaxis.min).toBe(0) + expect(b.yaxis.max).toBe(scaleMax(cpu, scale)) + const mem = chartOptions(find(charts, 'memory'), { group: 'g', locale: 'en', scale }) + expect(mem.yaxis.labels.formatter(1536)).toBe('1.5 KB') + expect(typeof a.tooltip.x.formatter).toBe('function') + expect(a.tooltip.shared).toBe(true) + }) + + it('takes the zoom, and holds no string with markup', () => { + const o = chartOptions(find(charts, 'cpu'), { group: 'g', zoom: { min: 1, max: 2 }, scale }) + expect([o.xaxis.min, o.xaxis.max]).toEqual([1, 2]) + expect(chartOptions(find(charts, 'cpu'), { group: 'g' }).xaxis.min).toBeNull() + expect(JSON.stringify(o)).not.toContain('<') + }) +}) + +describe('scaleMax', () => { + const chart = (id, ys, stacked = false) => ({ id, stacked, series: [{ key: `${id}/a`, kind: 'area', points: ys.map((y, x) => ({ x, y })) }] }) + it.each([ + ['cpu', [480], { cpu_allocation: 4 }, 528], + ['cpu', [120], { cpu_allocation: 4 }, 440], + ['cpu', [50], { cpu_allocation: Number.NaN }, 55], + ['memory', [100], { memory_limit: 1000 }, 1100], + ['memory', [2000], { memory_limit: null }, 2200], + ['cpu', [Number.NaN, null], {}, undefined], + ['cpu', [], undefined, undefined], + ])('%s %j with %j gives %j', (id, ys, scale, expected) => { + const got = scaleMax(chart(id, ys), scale) + if (expected === undefined) { + expect(got).toBeUndefined() + } else { + expect(got).toBeCloseTo(expected) + } + }) + + it('sums a stacked chart', () => { + expect(scaleMax({ id: 'cpu', stacked: true, series: [{ key: 'a', kind: 'area', points: [{ x: 1, y: 10 }] }, { key: 'b', kind: 'area', points: [{ x: 1, y: 20 }] }] }, {})).toBeCloseTo(33) + }) +}) + +describe('noteText', () => { + it('has its own sentence for the five known codes', () => { + const texts = ['vm_memory_used_unavailable', 'no_cpu_limit', 'no_memory_limit', 'container_gone', 'stats_capacity'].map(c => noteText(c, t)) + expect(new Set(texts).size).toBe(5) + for (const text of texts) { + expect(text).not.toContain('Note from the service') + } + }) + + it('says an unknown code as text', () => { + expect(noteText('x', t)).toBe('Note from the service: x') + expect(noteText('__proto__', t)).toBe('Note from the service: __proto__') + expect(noteText(5, t)).toBe('Note from the service: ') + }) +}) + +describe('chartCaption', () => { + it('says the scale', () => { + expect(chartCaption({ id: 'cpu' }, { cpu_allocation: 4 }, [], t)).toBe('% of one core · 4 cores allocated') + expect(chartCaption({ id: 'cpu' }, { cpu_allocation: 4 }, ['no_cpu_limit'], t)).toBe('% of one core · no limit set') + expect(chartCaption({ id: 'memory' }, { memory_limit: 4 * 1024 ** 3 }, [], t, 'en')).toBe('on 4 GB allowed') + expect(chartCaption({ id: 'disk' }, {}, [], t)).toBe('') + }) +}) + +describe('figures', () => { + const item = { kind: 'app-container' } + const text = (list, id) => list.find(f => f.id === id).text + + it('has six figures, and shows a stale last point as a hyphen', () => { + const fresh = figures(aSeries(), item, 'en', t) + expect(fresh.map(f => f.id)).toEqual(['cpu', 'memory', 'rx', 'tx', 'disk', 'processes']) + expect(text(fresh, 'rx')).not.toBe('-') + const old = pts([5, 5], 5, 1000) + const stale = figures(resp([['network', 'rx:eth0', 'bytes_per_second', old]], { to: 1000 + 5 + 16, step_seconds: 5 }), item, 'en', t) + expect(text(stale, 'rx')).toBe('-') + const ok = figures(resp([['network', 'rx:eth0', 'bytes_per_second', old]], { to: 1000 + 5 + 15, step_seconds: 5 }), item, 'en', t) + expect(text(ok, 'rx')).toBe('5 B/s') + }) + + it('sums the instances', () => { + const list = figures(resp([['network', 'rx:a', 'bytes_per_second', pts([1, 2])], ['network', 'rx:b', 'bytes_per_second', pts([3, 4])]], { to: 1005 }), item, 'en', t) + expect(text(list, 'rx')).toBe('6 B/s') + }) + + it('a VM shows memory/assigned when memory/used is missing, and vcpus last', () => { + const response = resp([['memory', 'assigned', 'bytes', pts([2048, 2048])], ['cpu', 'vcpus', 'count', pts([2, 2])]], { to: 1005 }) + const list = figures(response, { kind: 'vm' }, 'en', t) + expect(text(list, 'memory')).toBe('2 KB') + expect(list.at(-1).id).toBe('vcpus') + expect(text(list, 'vcpus')).toBe('2') + }) + + it('gives hyphens for nothing', () => { + expect(figures(null, item, 'en', t).every(f => f.text === '-')).toBe(true) + }) +}) + +describe('tableRows', () => { + it('gives last, min, average and max, and hyphens for all-null', () => { + const response = resp([['disk', 'read', 'bytes_per_second', [[1000, 2, 1, 4], [1005, 4, 3, 8]]], ['disk', 'write', 'bytes_per_second', pts([null, null])]]) + const rows = tableRows(find(buildCharts(response, { t }), 'disk'), 'en') + expect(rows[0]).toEqual({ name: 'Read', last: '4 B/s', min: '1 B/s', avg: '3 B/s', max: '8 B/s' }) + expect(rows[1]).toEqual({ name: 'Write', last: '-', min: '-', avg: '-', max: '-' }) + }) +}) + +describe('stats keys', () => { + it('is a list of unique strings', () => { + expect(STATS_KEYS.every(k => typeof k === 'string')).toBe(true) + expect(new Set(STATS_KEYS).size).toBe(STATS_KEYS.length) + }) +}) From 3b6083aa8c90aa403e50ed37f037dea1f3babd1f Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 19:08:36 +0200 Subject: [PATCH 04/20] feat(stats): a polling chain with one request in flight --- src/components/Stats/poller.js | 79 +++++++++++++++++ src/components/Stats/poller.spec.js | 127 ++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/components/Stats/poller.js create mode 100644 src/components/Stats/poller.spec.js diff --git a/src/components/Stats/poller.js b/src/components/Stats/poller.js new file mode 100644 index 00000000..3c0f35bf --- /dev/null +++ b/src/components/Stats/poller.js @@ -0,0 +1,79 @@ +// A polling chain: ask, wait for the answer, wait intervalMs, ask again. One request in flight, nothing asked while the tab is hidden. +export function createPoller({ run, intervalMs = 5000, isHidden = () => document.hidden }) { + let timer = null + let flying = false + let again = false + let active = false + let generation = 0 + + const clear = () => { + clearTimeout(timer) + timer = null + } + + function schedule() { + clear() + if (active && !isHidden()) { + timer = setTimeout(ask, intervalMs) + } + } + + async function ask() { + clear() + if (flying) { + again = true + return + } + flying = true + const mine = generation + try { + await run() + } catch { + // a failed ask is the caller's to show; the chain goes on + } + if (mine !== generation) { + return + } + flying = false + if (again) { + again = false + ask() + } else { + schedule() + } + } + + return { + start() { + if (active) { + return + } + active = true + if (!isHidden()) { + ask() + } + }, + stop() { + active = false + generation++ + flying = false + again = false + clear() + }, + now() { + if (active && !isHidden()) { + ask() + } + }, + visibilityChanged() { + if (!active) { + return + } + if (isHidden()) { + clear() + } else { + ask() + } + }, + } +} diff --git a/src/components/Stats/poller.spec.js b/src/components/Stats/poller.spec.js new file mode 100644 index 00000000..bf92d55f --- /dev/null +++ b/src/components/Stats/poller.spec.js @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPoller } from './poller' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +// a run that answers when told to +function deferred() { + const calls = [] + const run = vi.fn(() => new Promise((resolve, reject) => calls.push({ resolve, reject }))) + return { run, calls } +} +const settle = () => vi.advanceTimersByTimeAsync(0) + +describe('createPoller', () => { + it('keeps one request in flight', async () => { + const { run } = deferred() + const p = createPoller({ run, isHidden: () => false }) + p.start() + await vi.advanceTimersByTimeAsync(60000) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('asks again 5000 ms after the answer, not after the start', async () => { + const { run, calls } = deferred() + const p = createPoller({ run, isHidden: () => false }) + p.start() + await vi.advanceTimersByTimeAsync(3000) + calls[0].resolve() + await settle() + await vi.advanceTimersByTimeAsync(4999) + expect(run).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('re-arms after a failure', async () => { + const { run, calls } = deferred() + const p = createPoller({ run, isHidden: () => false }) + p.start() + calls[0].reject(new Error('down')) + await settle() + await vi.advanceTimersByTimeAsync(5000) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('asks nothing while hidden, and once when shown again', async () => { + let hidden = true + const run = vi.fn(() => Promise.resolve()) + const p = createPoller({ run, isHidden: () => hidden }) + p.start() + await vi.advanceTimersByTimeAsync(3600000) + expect(run).not.toHaveBeenCalled() + hidden = false + p.visibilityChanged() + await settle() + expect(run).toHaveBeenCalledTimes(1) + }) + + it('stops the timer when hidden', async () => { + let hidden = false + const run = vi.fn(() => Promise.resolve()) + const p = createPoller({ run, isHidden: () => hidden }) + p.start() + await settle() + hidden = true + p.visibilityChanged() + await vi.advanceTimersByTimeAsync(60000) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('now() during a flight gives one extra call after it ends', async () => { + const { run, calls } = deferred() + const p = createPoller({ run, isHidden: () => false }) + p.start() + p.now() + p.now() + expect(run).toHaveBeenCalledTimes(1) + calls[0].resolve() + await settle() + expect(run).toHaveBeenCalledTimes(2) + }) + + it('now() without a flight asks at once and re-arms the chain', async () => { + const run = vi.fn(() => Promise.resolve()) + const p = createPoller({ run, isHidden: () => false }) + p.start() + await settle() + await vi.advanceTimersByTimeAsync(4000) + p.now() + await settle() + expect(run).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(4999) + expect(run).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(run).toHaveBeenCalledTimes(3) + }) + + it('stop() clears the timer and makes a late answer inert', async () => { + const { run, calls } = deferred() + const p = createPoller({ run, isHidden: () => false }) + p.start() + p.stop() + calls[0].resolve() + await vi.advanceTimersByTimeAsync(60000) + expect(run).toHaveBeenCalledTimes(1) + const q = vi.fn(() => Promise.resolve()) + const p2 = createPoller({ run: q, isHidden: () => false }) + p2.start() + await settle() + p2.stop() + await vi.advanceTimersByTimeAsync(60000) + expect(q).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('start() twice does not double the chain', async () => { + const run = vi.fn(() => Promise.resolve()) + const p = createPoller({ run, isHidden: () => false }) + p.start() + p.start() + await settle() + expect(run).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(5000) + expect(run).toHaveBeenCalledTimes(2) + }) +}) From b83e173aabf7d3bf11bd3e20be282e0e9420809d Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 19:13:08 +0200 Subject: [PATCH 05/20] fix(stats): plain instance labels, hidden-aware poller, honest last values --- src/components/Stats/poller.js | 5 +++-- src/components/Stats/poller.spec.js | 16 ++++++++++++++++ src/components/Stats/statsData.js | 13 ++++++++----- src/components/Stats/statsData.spec.js | 25 +++++++++++++++++++++++-- src/service/stats.js | 3 ++- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/components/Stats/poller.js b/src/components/Stats/poller.js index 3c0f35bf..b60d192d 100644 --- a/src/components/Stats/poller.js +++ b/src/components/Stats/poller.js @@ -35,8 +35,9 @@ export function createPoller({ run, intervalMs = 5000, isHidden = () => document return } flying = false - if (again) { - again = false + const wanted = again + again = false + if (wanted && !isHidden()) { ask() } else { schedule() diff --git a/src/components/Stats/poller.spec.js b/src/components/Stats/poller.spec.js index bf92d55f..200b879d 100644 --- a/src/components/Stats/poller.spec.js +++ b/src/components/Stats/poller.spec.js @@ -81,6 +81,22 @@ describe('createPoller', () => { expect(run).toHaveBeenCalledTimes(2) }) + it('now() during a flight then hidden: nothing until shown, then one', async () => { + let hidden = false + const { run, calls } = deferred() + const p = createPoller({ run, isHidden: () => hidden }) + p.start() + p.now() + hidden = true + calls[0].resolve() + await vi.advanceTimersByTimeAsync(60000) + expect(run).toHaveBeenCalledTimes(1) + hidden = false + p.visibilityChanged() + await settle() + expect(run).toHaveBeenCalledTimes(2) + }) + it('now() without a flight asks at once and re-arms the chain', async () => { const run = vi.fn(() => Promise.resolve()) const p = createPoller({ run, isHidden: () => false }) diff --git a/src/components/Stats/statsData.js b/src/components/Stats/statsData.js index a69c1c46..631d1dba 100644 --- a/src/components/Stats/statsData.js +++ b/src/components/Stats/statsData.js @@ -86,7 +86,7 @@ export function groupItems(items) { // Every key is `chart/name`, matched exactly or with an instance (`:eth0`). export const CHARTS = [ - { id: 'cpu', title: 'Processor', stacked: true, unit: 'percent', keys: [ + { id: 'cpu', title: 'Processor', stacked: true, wide: true, unit: 'percent', keys: [ { key: 'cpu/user', label: 'User', kind: 'area', color: PALETTE[0] }, { key: 'cpu/system', label: 'System', kind: 'area', color: PALETTE[1] }, { key: 'cpu/iowait', label: 'Waiting for disk', kind: 'area', color: PALETTE[2] }, @@ -223,7 +223,7 @@ function buildOne(def, byKey, { range, hidden, kind, t }, response) { const labelOf = (f) => { let base = f.key === 'cpu/vcpus' ? t(kind === 'vm' ? VCPUS_VM : VCPUS_HOST) : t(f.def.label) if (f.instance) { - base += ` · ${escape(clean(f.instance, 40))}` + base += ` · ${clean(f.instance, 40)}` } return base } @@ -381,12 +381,15 @@ export function chartOptions(chart, { group, zoom, height, locale = 'en', range function lastFresh(series, response) { const step = response.step_seconds const to = response.to + if (!isNumber(to) || !isNumber(step)) { + return null + } const points = cleanSeries(series.points, { stepSeconds: step }).points const p = [...points].reverse().find(q => q.avg !== null) if (!p) { return null } - return isNumber(to) && isNumber(step) && p.t < to - (3 * step) ? null : p.avg + return p.t < to - (3 * step) ? null : p.avg } export function figures(response, item, locale, t) { @@ -398,7 +401,7 @@ export function figures(response, item, locale, t) { } const vm = item?.kind === 'vm' const memory = vm ? (sum(['memory/used']) ?? sum(['memory/assigned'])) : sum(['memory/rss']) - const cpu = sum(['cpu/total']) ?? sum(['cpu/user', 'cpu/system']) + const cpu = sum(['cpu/total']) ?? sum(['cpu/user', 'cpu/system', 'cpu/iowait']) const last = vm ? { id: 'vcpus', label: t('Virtual CPUs'), value: sum(['cpu/vcpus']), unit: 'count' } : { id: 'processes', label: t('Processes'), value: sum(['pids/count']), unit: 'count' } return [ { id: 'cpu', label: t('Processor'), value: cpu, unit: 'percent' }, @@ -422,7 +425,7 @@ export function tableRows(chart, locale) { const maxs = pick('max') return { name: s.label, - last: show(avgs.at(-1)), + last: show(s.cleaned.at(-1)?.avg ?? null), min: show(Math.min(...(mins.length > 0 ? mins : avgs))), avg: show(avgs.reduce((a, b) => a + b, 0) / avgs.length), max: show(Math.max(...(maxs.length > 0 ? maxs : avgs))), diff --git a/src/components/Stats/statsData.spec.js b/src/components/Stats/statsData.spec.js index 56d008b1..d70b5d46 100644 --- a/src/components/Stats/statsData.spec.js +++ b/src/components/Stats/statsData.spec.js @@ -1,4 +1,5 @@ import escape from 'lodash/escape' +import unescape from 'lodash/unescape' import { describe, expect, it } from 'vitest' import { CONTAINER_SERIES, HOSTILE, HOST_SERIES, VM_SERIES, aSeries, anItem } from './__fixtures__/contract' import { GROUPS, STATS_KEYS, buildCharts, chartCaption, chartOptions, cleanSeries, figures, groupItems, listNotes, normalizeItems, noteText, scaleMax, tableRows } from './statsData' @@ -112,9 +113,11 @@ describe('buildCharts', () => { const charts = buildCharts(resp([['network', 'rx:eth0', 'bytes_per_second', pts([1, 2])]]), { t }) const s = find(charts, 'network').series[0] expect(s.name).not.toContain('<') - expect(s.label).toContain('<img') + expect(s.label).toContain(' { expect(text(list, 'vcpus')).toBe('2') }) + it('shows a hyphen when the response has no to or step_seconds', () => { + const r = resp([['network', 'rx:eth0', 'bytes_per_second', pts([5, 5])]], { to: undefined }) + expect(text(figures(r, item, 'en', t), 'rx')).toBe('-') + expect(text(figures({ ...r, to: 1005, step_seconds: undefined }, item, 'en', t), 'rx')).toBe('-') + }) + + it('the processor of a container adds user, system and iowait', () => { + const r = resp([['cpu', 'user', 'percent', pts([1, 1])], ['cpu', 'system', 'percent', pts([2, 2])], ['cpu', 'iowait', 'percent', pts([4, 4])]], { to: 1005 }) + expect(text(figures(r, item, 'en', t), 'cpu')).toBe('7%') + }) + it('gives hyphens for nothing', () => { expect(figures(null, item, 'en', t).every(f => f.text === '-')).toBe(true) }) @@ -293,6 +307,13 @@ describe('tableRows', () => { expect(rows[0]).toEqual({ name: 'Read', last: '4 B/s', min: '1 B/s', avg: '3 B/s', max: '8 B/s' }) expect(rows[1]).toEqual({ name: 'Write', last: '-', min: '-', avg: '-', max: '-' }) }) + + it('shows a hyphen as last after a trailing gap', () => { + const response = resp([['disk', 'read', 'bytes_per_second', pts([4, 6, null])]]) + const [row] = tableRows(find(buildCharts(response, { t }), 'disk'), 'en') + expect(row.last).toBe('-') + expect(row.max).toBe('6 B/s') + }) }) describe('stats keys', () => { diff --git a/src/service/stats.js b/src/service/stats.js index 40a4f5b6..28b0042d 100644 --- a/src/service/stats.js +++ b/src/service/stats.js @@ -1,5 +1,4 @@ // The statistics clients: one per service prefix, same four calls. -import { isDots } from '@/components/VirtualMachines/vms' import { api } from '@/service/service.js' export const STATS_SOURCES = ['app_management', 'virt_management', 'core'] @@ -20,6 +19,8 @@ function base(source) { return PREFIX[source] } +const isDots = id => /^\.+$/.test(id) + // An id comes from a service or the bus: it must stay one segment of the route. function seg(id) { if (typeof id !== 'string' || id === '' || isDots(id)) { From 8cab309e9666bb8a8a732c64946ac11c9d5891ff Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 19:18:36 +0200 Subject: [PATCH 06/20] feat(stats): a Statistics system app, lazily loaded --- src/assets/img/app/stats.svg | 14 ++++ src/assets/lang/en_US.json | 3 +- src/assets/lang/fr_FR.json | 3 +- src/components/Apps/AppCard.vue | 4 ++ src/components/Apps/AppSection.spec.js | 20 ++++++ src/components/Apps/AppSection.vue | 11 +++ src/components/Stats/StatsPage.vue | 13 ++++ src/components/Stats/StatsTab.vue | 62 ++++++++++++++++ src/components/Stats/entry.js | 30 ++++++++ src/components/Stats/entry.spec.js | 71 +++++++++++++++++++ .../settings/AppLaunchModal.spec.js | 46 ++++++++++++ src/components/settings/AppLaunchModal.vue | 3 +- src/mixins/app/appLaunchPreference.js | 1 + src/mixins/app/appLaunchPreference.spec.js | 8 +++ src/router/route.js | 10 +++ 15 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 src/assets/img/app/stats.svg create mode 100644 src/components/Stats/StatsPage.vue create mode 100644 src/components/Stats/StatsTab.vue create mode 100644 src/components/Stats/entry.js create mode 100644 src/components/Stats/entry.spec.js create mode 100644 src/components/settings/AppLaunchModal.spec.js diff --git a/src/assets/img/app/stats.svg b/src/assets/img/app/stats.svg new file mode 100644 index 00000000..b91e7e8f --- /dev/null +++ b/src/assets/img/app/stats.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/lang/en_US.json b/src/assets/lang/en_US.json index d6c10106..6cdf0a38 100644 --- a/src/assets/lang/en_US.json +++ b/src/assets/lang/en_US.json @@ -2403,5 +2403,6 @@ "Files that were left as they are": "Files that were left as they are", "The update of {module} is installed, but its backup is still there": "The update of {module} is installed, but its backup is still there", "The update failed and was rolled back, but some files were left as they are, so the old version is not whole. They are named below and in the log. Press Remove, then Install, to get a clean module.": "The update failed and was rolled back, but some files were left as they are, so the old version is not whole. They are named below and in the log. Press Remove, then Install, to get a clean module.", - "The update is installed, but its backup folder could not be deleted (its path is below). The next install, update or remove will put the old version back unless the folder is deleted by hand.": "The update is installed, but its backup folder could not be deleted (its path is below). The next install, update or remove will put the old version back unless the folder is deleted by hand." + "The update is installed, but its backup folder could not be deleted (its path is below). The next install, update or remove will put the old version back unless the folder is deleted by hand.": "The update is installed, but its backup folder could not be deleted (its path is below). The next install, update or remove will put the old version back unless the folder is deleted by hand.", + "Statistics": "Statistics" } diff --git a/src/assets/lang/fr_FR.json b/src/assets/lang/fr_FR.json index 389accc1..20e3108b 100644 --- a/src/assets/lang/fr_FR.json +++ b/src/assets/lang/fr_FR.json @@ -2403,5 +2403,6 @@ "Files that were left as they are": "Fichiers laissés tels quels", "The update of {module} is installed, but its backup is still there": "La mise à jour de {module} est installée, mais sa sauvegarde est restée", "The update failed and was rolled back, but some files were left as they are, so the old version is not whole. They are named below and in the log. Press Remove, then Install, to get a clean module.": "La mise à jour a échoué et a été annulée, mais certains fichiers ont été laissés tels quels : l'ancienne version n'est donc pas complète. Ils sont nommés ci-dessous et dans le journal. Appuyez sur Supprimer, puis sur Installer, pour obtenir un module propre.", - "The update is installed, but its backup folder could not be deleted (its path is below). The next install, update or remove will put the old version back unless the folder is deleted by hand.": "La mise à jour est installée, mais son dossier de sauvegarde n'a pas pu être supprimé (son chemin est ci-dessous). La prochaine installation, mise à jour ou suppression remettra l'ancienne version en place, sauf si le dossier est supprimé à la main." + "The update is installed, but its backup folder could not be deleted (its path is below). The next install, update or remove will put the old version back unless the folder is deleted by hand.": "La mise à jour est installée, mais son dossier de sauvegarde n'a pas pu être supprimé (son chemin est ci-dessous). La prochaine installation, mise à jour ou suppression remettra l'ancienne version en place, sauf si le dossier est supprimé à la main.", + "Statistics": "Statistiques" } diff --git a/src/components/Apps/AppCard.vue b/src/components/Apps/AppCard.vue index 1b4b0f0e..b2431ed8 100644 --- a/src/components/Apps/AppCard.vue +++ b/src/components/Apps/AppCard.vue @@ -188,6 +188,7 @@ import { ageKey, containerFacts } from './legacyApps' import { gitBadge, withoutContainer } from './gitApps' import BackupAppModal from './BackupAppModal.vue' import ContainerDetailPanel from './ContainerDetailPanel.vue' +import { STATS_APP_NAME, openStatistics } from '@/components/Stats/entry' import { VM_APP_NAME, openVirtualMachines } from '@/components/VirtualMachines/entry' import events from '@/events/events' import cTooltip from '@/components/basicComponents/tooltip/tooltip.vue' @@ -452,6 +453,9 @@ export default { case VM_APP_NAME: openVirtualMachines(this.$buefy, this.$store.state) break + case STATS_APP_NAME: + openStatistics(this.$buefy, this.$store.state) + break default: break } diff --git a/src/components/Apps/AppSection.spec.js b/src/components/Apps/AppSection.spec.js index 73c9ae82..3b74ef38 100644 --- a/src/components/Apps/AppSection.spec.js +++ b/src/components/Apps/AppSection.spec.js @@ -209,3 +209,23 @@ describe('app section when a module is installed or removed', () => { expect(names(vm)).toEqual(['App Store', 'Virtual Machines']) }) }) + +describe('app section built-in cards', () => { + it('always carries Statistics next to Files', async () => { + const vm = { + $baseIp: 'localhost', + $openAPI: { appGrid: { getAppGrid: () => Promise.resolve({ data: { data: [] } }) } }, + $api: { users: { getCustomStorage: () => Promise.resolve({ data: { data: { data: [] } } }) } }, + appList: [], + getLinkAppList: () => Promise.resolve([]), + sortApps: AppSection.methods.sortApps, + saveSortIfChanged: vi.fn(), + addVmCardWhenThere: vi.fn(), + } + await AppSection.methods.getList.call(vm) + const names = vm.appList.map(app => app.name) + expect(names).toContain('Files') + expect(names).toContain('Statistics') + expect(vm.appList.find(app => app.name === 'Statistics')).toMatchObject({ app_type: 'system', status: 'running' }) + }) +}) diff --git a/src/components/Apps/AppSection.vue b/src/components/Apps/AppSection.vue index 14056dc0..0b04db03 100644 --- a/src/components/Apps/AppSection.vue +++ b/src/components/Apps/AppSection.vue @@ -107,6 +107,7 @@ import ExternalLinkPanel from '@/components/Apps/ExternalLinkPanel' import GitAppModal from '@/components/Apps/GitAppModal.vue' import { withoutContainer } from '@/components/Apps/gitApps' import { APP_STORE_LAUNCH_ID, builtinOpensInNewTab } from '@/mixins/app/appLaunchPreference' +import { STATS_APP_NAME } from '@/components/Stats/entry' import { VM_APP_NAME, forgetVirtualMachines, hasVirtualMachines, vmApplication } from '@/components/VirtualMachines/entry' import UpdateAllModal from '@/components/Apps/UpdateAllModal.vue' import { imageUpdateSummary } from '@/components/Apps/imageUpdateSummary' @@ -141,6 +142,16 @@ const builtInApplications = [ status: 'running', app_type: 'system', }, + { + id: '3', + name: STATS_APP_NAME, + title: { + en_us: 'Statistics', + }, + icon: require(`@/assets/img/app/stats.svg`), + status: 'running', + app_type: 'system', + }, ] const orderConfig = 'app_order' diff --git a/src/components/Stats/StatsPage.vue b/src/components/Stats/StatsPage.vue new file mode 100644 index 00000000..ed51cffe --- /dev/null +++ b/src/components/Stats/StatsPage.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/Stats/StatsTab.vue b/src/components/Stats/StatsTab.vue new file mode 100644 index 00000000..cbd83dc3 --- /dev/null +++ b/src/components/Stats/StatsTab.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/components/Stats/entry.js b/src/components/Stats/entry.js new file mode 100644 index 00000000..9463aae0 --- /dev/null +++ b/src/components/Stats/entry.js @@ -0,0 +1,30 @@ +// What the rest of the dashboard knows about Statistics: the name of its card, and how to open the page. The page itself +// (and apexcharts with it) is loaded on demand. +import { defineAsyncComponent } from 'vue' +import { openWithEscape } from '@/components/VirtualMachines/modalStack' +import { STATS_LAUNCH_ID, builtinOpensInNewTab } from '@/mixins/app/appLaunchPreference' + +const StatsPage = defineAsyncComponent(() => import('./StatsPage.vue')) + +// The name of the card in the app grid, which also keeps its place in the saved order. +export const STATS_APP_NAME = 'Statistics' + +// Opens the page in a modal, or on a tab of its own when the App launching setting sends apps, or this one, to a new tab. +// `item` is a list key of the page ('source|id' is not needed: the id the service gave). +export function openStatistics(buefy, state, { item } = {}) { + if (builtinOpensInNewTab(STATS_LAUNCH_ID, state)) { + const query = item ? `?item=${encodeURIComponent(item)}` : '' + window.open(`${window.location.pathname}#/stats${query}`, '_blank') + return null + } + + return openWithEscape(buefy, { + component: StatsPage, + props: { initialItem: item || '' }, + hasModalCard: true, + trapFocus: true, + scroll: 'keep', + animation: 'zoom-in', + customClass: 'stats-modal', + }) +} diff --git a/src/components/Stats/entry.spec.js b/src/components/Stats/entry.spec.js new file mode 100644 index 00000000..7276c6ed --- /dev/null +++ b/src/components/Stats/entry.spec.js @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// counts how many times the page module is evaluated +const loaded = vi.hoisted(() => ({ count: 0 })) +vi.mock('./StatsPage.vue', () => { + loaded.count++ + return { default: { name: 'StatsPage', render: () => null } } +}) + +function fakeBuefy() { + const open = vi.fn(options => ({ options, close: vi.fn() })) + return { buefy: { modal: { open } }, open } +} + +describe('the Statistics entry', () => { + beforeEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + it('does not load the page when imported', async () => { + await import('./entry.js') + expect(loaded.count).toBe(0) + }) + + it('opens a modal that Escape of its own closes, with no initial item', async () => { + const { openStatistics, STATS_APP_NAME } = await import('./entry.js') + const { buefy, open } = fakeBuefy() + const modal = openStatistics(buefy, { appLaunchInIframe: true, appLaunchExceptions: [] }) + expect(STATS_APP_NAME).toBe('Statistics') + expect(modal).not.toBeNull() + const options = open.mock.calls[0][0] + expect(options.canCancel).toEqual([]) + expect(options.props.initialItem).toBe('') + expect(options.customClass).toContain('stats-modal') + expect(options.hasModalCard).toBe(true) + }) + + it('hands over the item it was asked for', async () => { + const { openStatistics } = await import('./entry.js') + const { buefy, open } = fakeBuefy() + openStatistics(buefy, { appLaunchInIframe: true, appLaunchExceptions: [] }, { item: 'vm:abc' }) + expect(open.mock.calls[0][0].props.initialItem).toBe('vm:abc') + }) + + it('opens a tab when apps open in new tabs, and returns null', async () => { + const { openStatistics } = await import('./entry.js') + const { buefy, open } = fakeBuefy() + const win = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openStatistics(buefy, { appLaunchInIframe: false, appLaunchExceptions: [] })).toBeNull() + expect(win).toHaveBeenCalledTimes(1) + expect(win.mock.calls[0][0].endsWith('#/stats')).toBe(true) + expect(win.mock.calls[0][1]).toBe('_blank') + expect(open).not.toHaveBeenCalled() + }) + + it('puts the item in the address of the tab, encoded', async () => { + const { openStatistics } = await import('./entry.js') + const win = vi.spyOn(window, 'open').mockReturnValue(null) + openStatistics(fakeBuefy().buefy, { appLaunchInIframe: false, appLaunchExceptions: [] }, { item: 'vm:abc/..' }) + expect(win.mock.calls[0][0].endsWith('#/stats?item=vm%3Aabc%2F..')).toBe(true) + }) + + it('opens a tab when Statistics is among the exceptions', async () => { + const { openStatistics } = await import('./entry.js') + const win = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openStatistics(fakeBuefy().buefy, { appLaunchInIframe: true, appLaunchExceptions: ['casaos:statistics'] })).toBeNull() + expect(win).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/settings/AppLaunchModal.spec.js b/src/components/settings/AppLaunchModal.spec.js new file mode 100644 index 00000000..b12b1716 --- /dev/null +++ b/src/components/settings/AppLaunchModal.spec.js @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppLaunchModal from './AppLaunchModal.vue' + +const hasVMs = vi.hoisted(() => ({ value: false })) +vi.mock('@/components/VirtualMachines/entry', async importOriginal => ({ + ...await importOriginal(), + hasVirtualMachines: () => Promise.resolve(hasVMs.value), +})) + +async function open() { + const wrapper = mount(AppLaunchModal, { + global: { + mocks: { + $t: key => key, + $store: { state: { appLaunchInIframe: true, appLaunchExceptions: [] } }, + $openAPI: { appManagement: { compose: { myComposeAppList: () => Promise.resolve({ data: { data: {} } }) } } }, + }, + stubs: { 'b-field': true, 'b-switch': true, 'b-loading': true, 'b-message': true, 'b-button': true, 'b-checkbox': { template: '' } }, + }, + }) + await flushPromises() + return wrapper +} + +describe('the built-in apps of the App launching setting', () => { + beforeEach(() => { + hasVMs.value = false + }) + + it('lists Statistics, and no Virtual Machines without the module', async () => { + const wrapper = await open() + const titles = wrapper.vm.builtins.map(b => b.title) + expect(titles).toContain('Statistics') + expect(titles).not.toContain('Virtual Machines') + wrapper.unmount() + }) + + it('lists Virtual Machines too when the module is there', async () => { + hasVMs.value = true + const wrapper = await open() + expect(wrapper.vm.builtins.map(b => b.title)).toEqual(expect.arrayContaining(['Statistics', 'Virtual Machines'])) + wrapper.unmount() + }) +}) diff --git a/src/components/settings/AppLaunchModal.vue b/src/components/settings/AppLaunchModal.vue index 36c1c114..253ead56 100644 --- a/src/components/settings/AppLaunchModal.vue +++ b/src/components/settings/AppLaunchModal.vue @@ -55,7 +55,7 @@ + + From ae1bbc29d4cbab914b89de77fcbebbf8cb1785ab Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 19:27:16 +0200 Subject: [PATCH 08/20] feat(stats): periods, figures and series fetching --- src/assets/lang/en_US.json | 17 +- src/assets/lang/fr_FR.json | 17 +- src/components/Stats/StatsPage.spec.js | 226 ++++++++++++++++++++- src/components/Stats/StatsPage.vue | 262 ++++++++++++++++++++++++- 4 files changed, 516 insertions(+), 6 deletions(-) diff --git a/src/assets/lang/en_US.json b/src/assets/lang/en_US.json index e9619701..6e0a5103 100644 --- a/src/assets/lang/en_US.json +++ b/src/assets/lang/en_US.json @@ -2413,5 +2413,20 @@ "Machine": "Machine", "Other containers": "Other containers", "Note from the service: {code}": "Note from the service: {code}", - "Too many items: some of them are not measured.": "Too many items: some of them are not measured." + "Too many items: some of them are not measured.": "Too many items: some of them are not measured.", + "Period": "Period", + "Live": "Live", + "15 min": "15 min", + "1 h": "1 h", + "24 h": "24 h", + "7 days": "7 days", + "The measurements could not be read.": "The measurements could not be read.", + "This item is no longer there.": "This item is no longer there.", + "Running since {since}": "Running since {since}", + "Virtual machine": "Virtual machine", + "Received": "Received", + "Disk": "Disk", + "Processes": "Processes", + "Virtual CPUs": "Virtual CPUs", + "No data for this period": "No data for this period" } diff --git a/src/assets/lang/fr_FR.json b/src/assets/lang/fr_FR.json index 044e6a20..355e3a8a 100644 --- a/src/assets/lang/fr_FR.json +++ b/src/assets/lang/fr_FR.json @@ -2413,5 +2413,20 @@ "Machine": "Machine", "Other containers": "Autres conteneurs", "Note from the service: {code}": "Remarque du service : {code}", - "Too many items: some of them are not measured.": "Trop d'éléments : certains ne sont pas mesurés." + "Too many items: some of them are not measured.": "Trop d'éléments : certains ne sont pas mesurés.", + "Period": "Période", + "Live": "En direct", + "15 min": "15 min", + "1 h": "1 h", + "24 h": "24 h", + "7 days": "7 jours", + "The measurements could not be read.": "Les mesures n'ont pas pu être lues.", + "This item is no longer there.": "Cet élément n'existe plus.", + "Running since {since}": "En marche depuis le {since}", + "Virtual machine": "Machine virtuelle", + "Received": "Reçu", + "Disk": "Disque", + "Processes": "Processus", + "Virtual CPUs": "Processeurs virtuels", + "No data for this period": "Aucune donnée pour cette période" } diff --git a/src/components/Stats/StatsPage.spec.js b/src/components/Stats/StatsPage.spec.js index 34317594..7bb27df6 100644 --- a/src/components/Stats/StatsPage.spec.js +++ b/src/components/Stats/StatsPage.spec.js @@ -2,7 +2,7 @@ import Buefy from 'buefy' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { HOSTILE, anItem } from './__fixtures__/contract' +import { HOSTILE, aSeries, anItem, makePoints } from './__fixtures__/contract' import StatsPage from './StatsPage.vue' import { hasVirtualMachines } from '@/components/VirtualMachines/entry' import { statsApi } from '@/service/stats' @@ -270,3 +270,227 @@ describe('the refresh of the list', () => { expect(vi.getTimerCount()).toBe(0) }) }) + +// a series answer whose points are the same value all along; null is a gap +function seriesWith(values, over = {}) { + return aSeries({ + series: Object.entries(values).map(([key, value]) => { + const slash = key.indexOf('/') + return { chart: key.slice(0, slash), name: key.slice(slash + 1), unit: 'percent', points: makePoints({ fn: () => value }) } + }), + ...over, + }) +} + +describe('the header, the periods and the figures', () => { + let hidden + beforeEach(() => { + document.body.innerHTML = '' + vi.clearAllMocks() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + hidden = false + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }) + hasVirtualMachines.mockResolvedValue(true) + statsApi.series.mockResolvedValue(seriesWith({ 'cpu/total': 38 })) + serve(defaults()) + }) + afterEach(() => { + wrapper?.unmount() + wrapper = null + delete document.hidden + vi.useRealTimers() + }) + + const tick = ms => vi.advanceTimersByTimeAsync(ms) + async function open(props = {}) { + wrapper = mount(StatsPage, { props, attachTo: document.body, global: { plugins: [Buefy, i18n] } }) + await tick(0) + return wrapper + } + const seriesCalls = () => statsApi.series.mock.calls.map(c => c.join('|')) + const rangeButton = label => [...document.querySelectorAll('[aria-label="Period"] button')].find(b => b.textContent.trim() === label) + const figureTexts = () => [...document.querySelectorAll('.stats-figures dd')].map(dd => dd.textContent) + const figureLabels = () => [...document.querySelectorAll('.stats-figures dt')].map(dt => dt.textContent) + const JELLY = 'app_management|app:jellyfin:web:1' + + it('asks the live series of the selected item first, and says so', async () => { + await open() + expect(seriesCalls()).toEqual([`${JELLY}|live`]) + expect(rangeButton('Live').getAttribute('aria-pressed')).toBe('true') + expect(rangeButton('7 days').getAttribute('aria-pressed')).toBe('false') + expect(document.querySelector('[role="group"][aria-label="Period"]')).not.toBeNull() + }) + + it('asks once for another period, starts no polling, and asks again on Refresh', async () => { + await open() + rangeButton('7 days').click() + await tick(0) + expect(seriesCalls()).toEqual([`${JELLY}|live`, `${JELLY}|7d`]) + await tick(60000) + expect(statsApi.series).toHaveBeenCalledTimes(2) + ;[...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Refresh').click() + await tick(0) + expect(statsApi.series).toHaveBeenCalledTimes(3) + }) + + it('polls the live series every 5 s, one request at a time', async () => { + statsApi.series.mockReturnValue(new Promise(() => {})) + await open() + await tick(60000) + expect(statsApi.series).toHaveBeenCalledTimes(1) + }) + + it('polls every 5 s once answered, pauses while hidden, asks once when shown', async () => { + await open() + await tick(5000) + expect(statsApi.series).toHaveBeenCalledTimes(2) + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + await tick(3600000) + expect(statsApi.series).toHaveBeenCalledTimes(2) + hidden = false + document.dispatchEvent(new Event('visibilitychange')) + await tick(0) + expect(statsApi.series).toHaveBeenCalledTimes(3) + }) + + it('ignores the late answer of the old item, and never shows its curves under the new name', async () => { + const first = deferred() + const second = deferred() + statsApi.series.mockImplementation((source, id) => id === 'app:jellyfin:web:1' ? first.promise : second.promise) + await open() + document.querySelectorAll('.stats-list button')[1].click() + await tick(0) + expect(document.querySelector('.stats-name').textContent).toBe('Plex') + first.resolve(seriesWith({ 'cpu/total': 11 })) + await tick(0) + expect(document.querySelector('.stats-figures')).toBeNull() + second.resolve(seriesWith({ 'cpu/total': 22 })) + await tick(0) + expect(figureTexts()[0]).toBe('22%') + }) + + it('clears the curves of the old item at once, but keeps them while the period of the same item changes', async () => { + await open() + expect(figureTexts()[0]).toBe('38%') + statsApi.series.mockReturnValue(new Promise(() => {})) + rangeButton('1 h').click() + await tick(0) + expect(figureTexts()[0]).toBe('38%') + document.querySelectorAll('.stats-list button')[1].click() + await tick(0) + expect(document.querySelector('.stats-figures')).toBeNull() + }) + + it('ignores the late answer of the old period', async () => { + const live = deferred() + const hour = deferred() + statsApi.series.mockImplementation((source, id, range) => range === 'live' ? live.promise : hour.promise) + await open() + rangeButton('1 h').click() + await tick(0) + live.resolve(seriesWith({ 'cpu/total': 11 })) + await tick(0) + expect(document.querySelector('.stats-figures')).toBeNull() + hour.resolve(seriesWith({ 'cpu/total': 22 })) + await tick(0) + expect(figureTexts()[0]).toBe('22%') + }) + + it('falls back to the first item when the series says the item is gone', async () => { + statsApi.series.mockImplementation((source, id) => { + if (id === 'app:jellyfin:web:1') { + serve({ ...defaults(), app_management: body([plex]) }) + return Promise.reject(failure(404)) + } + return Promise.resolve(seriesWith({ 'cpu/total': 38 })) + }) + await open() + await tick(0) + expect(seriesCalls()).toContain('app_management|app:plex:web:1|live') + expect(document.querySelector('.stats-name').textContent).toBe('Plex') + }) + + it('shows a retryable error and keeps the shell', async () => { + statsApi.series.mockRejectedValue(failure(500)) + await open() + expect(document.querySelector('.stats-series-error').textContent).toContain('The measurements could not be read.') + expect(document.querySelector('.stats-name').textContent).toBe('Jellyfin') + expect(names()).toContain('Plex') + statsApi.series.mockClear() + statsApi.series.mockResolvedValue(seriesWith({ 'cpu/total': 38 })) + document.querySelector('.stats-series-error button').click() + await tick(0) + expect(statsApi.series).toHaveBeenCalledTimes(1) + expect(document.querySelector('.stats-series-error')).toBeNull() + }) + + it('shows six figures, and a dash, not NaN, for a missing value', async () => { + await open() + expect(figureLabels()).toEqual(['Processor', 'Memory', 'Received', 'Sent', 'Disk', 'Processes']) + expect(figureTexts()).toEqual(['38%', '-', '-', '-', '-', '-']) + expect(document.querySelector('.stats-figures').textContent).not.toContain('NaN') + }) + + it('shows virtual processors for a virtual machine in place of processes', async () => { + await open({ initialItem: 'vm-1' }) + expect(figureLabels()).toContain('Virtual CPUs') + expect(figureLabels()).not.toContain('Processes') + }) + + it('shows the notes of the series as text', async () => { + statsApi.series.mockResolvedValue(seriesWith({ 'cpu/total': 38 }, { notes: ['vm_memory_used_unavailable', 'x'] })) + await open() + const notes = [...document.querySelectorAll('.stats-series-note')].map(n => n.textContent) + expect(notes).toEqual(['The memory used inside the virtual machine is not reported.', 'Note from the service: x']) + expect(document.querySelector('.stats-series-note b')).toBeNull() + }) + + it('says there is no data for a period that is all gaps, and draws no chart', async () => { + statsApi.series.mockResolvedValue(seriesWith({ 'cpu/total': null, 'memory/rss': null })) + await open() + expect(document.querySelector('.stats-no-data').textContent).toBe('No data for this period') + expect(document.querySelector('.stats-chart')).toBeNull() + }) + + it('shows since when an item runs, only for a running or paused item that has a start', async () => { + serve({ ...defaults(), app_management: body([anItem({ id: 'a', name: 'A' }), anItem({ id: 'b', name: 'B', state: 'stopped' }), anItem({ id: 'c', name: 'C', started_at: null }), anItem({ id: 'd', name: 'D', state: 'paused' })]) }) + await open() + const since = () => document.querySelector('.stats-since')?.textContent + expect(since()).toContain('Running since') + document.querySelectorAll('.stats-list button')[1].click() + await tick(0) + expect(document.querySelector('.stats-name').textContent).toBe('B') + expect(since()).toBeUndefined() + document.querySelectorAll('.stats-list button')[2].click() + await tick(0) + expect(document.querySelector('.stats-name').textContent).toBe('C') + expect(since()).toBeUndefined() + document.querySelectorAll('.stats-list button')[3].click() + await tick(0) + expect(since()).toContain('Running since') + }) + + it('shows the detail as one line of text, cut at 200 characters, the same in its title', async () => { + serve({ ...defaults(), app_management: body([anItem({ detail: HOSTILE })]) }) + await open() + expect(document.querySelector('.stats-detail img')).toBeNull() + expect(document.querySelector('.stats-detail').textContent).toBe(HOSTILE) + expect(document.querySelector('.stats-detail').getAttribute('title')).toBe(HOSTILE) + wrapper.unmount() + document.body.innerHTML = '' + serve({ ...defaults(), app_management: body([anItem({ detail: 'x'.repeat(300) })]) }) + await open() + expect(document.querySelector('.stats-detail').textContent).toHaveLength(200) + expect(document.querySelector('.stats-detail').getAttribute('title')).toHaveLength(200) + }) + + it('shows the name, the kind and the state of the item as text', async () => { + serve({ ...defaults(), app_management: body([anItem({ name: HOSTILE })]) }) + await open() + expect(document.querySelector('.stats-name img')).toBeNull() + expect(document.querySelector('.stats-name').textContent).toBe(HOSTILE) + expect(document.querySelector('.stats-kind').textContent).toBe('Container') + expect(document.querySelector('.stats-state').textContent).toBe('Running') + }) +}) diff --git a/src/components/Stats/StatsPage.vue b/src/components/Stats/StatsPage.vue index f7b57ccd..14b6a7bc 100644 --- a/src/components/Stats/StatsPage.vue +++ b/src/components/Stats/StatsPage.vue @@ -50,7 +50,51 @@

{{ note.text }}

-
+
+ +
@@ -58,8 +102,8 @@ @@ -362,6 +523,101 @@ export default { } } + .stats-main { + position: relative; + min-width: 0; + min-height: 20rem; + } + + .stats-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; + } + + .stats-name { + font-size: 1.125rem; + font-weight: 600; + color: var(--casa-text-strong); + overflow-wrap: anywhere; + } + + .stats-kind, + .stats-state, + .stats-since, + .stats-detail { + font-size: 0.75rem; + color: var(--casa-text-dim); + } + + .stats-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .stats-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0.75rem 0; + } + + .stats-ranges { + display: inline-flex; + border-radius: 1rem; + border: 1px solid var(--casa-border); + overflow: hidden; + + button { + padding: 0.25rem 0.75rem; + border: 0; + font-size: 0.75rem; + color: inherit; + background: transparent; + cursor: pointer; + + &.is-active { + color: #fff; + background: var(--casa-primary, #3b82f6); + } + } + } + + .stats-series-error, + .stats-series-note, + .stats-no-data { + margin-bottom: 0.5rem; + font-size: 0.8125rem; + color: var(--casa-text-dim); + } + + .stats-figures { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.5rem; + margin-bottom: 1rem; + } + + .stats-figure { + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--casa-border); + + dt { + font-size: 0.75rem; + color: var(--casa-text-dim); + } + + dd { + font-size: 1.125rem; + font-weight: 600; + color: var(--casa-text-strong); + } + } + .stats-column-error, .stats-note { padding: 0 0.625rem; From d3be604f755b6ff613a3ff4a838c314057e817a0 Mon Sep 17 00:00:00 2001 From: Gary Date: Tue, 20 Oct 2026 19:33:47 +0200 Subject: [PATCH 09/20] fix(stats): stale columns, initial item, gone items, plurals --- src/assets/lang/en_US.json | 5 +- src/assets/lang/fr_FR.json | 5 +- src/components/Stats/StatsPage.spec.js | 98 ++++++++++++++++++++++++++ src/components/Stats/StatsPage.vue | 81 ++++++++++++++++----- src/components/Stats/entry.js | 2 +- src/components/Stats/statsData.js | 2 +- 6 files changed, 171 insertions(+), 22 deletions(-) diff --git a/src/assets/lang/en_US.json b/src/assets/lang/en_US.json index 6e0a5103..9bb776fa 100644 --- a/src/assets/lang/en_US.json +++ b/src/assets/lang/en_US.json @@ -2428,5 +2428,8 @@ "Disk": "Disk", "Processes": "Processes", "Virtual CPUs": "Virtual CPUs", - "No data for this period": "No data for this period" + "No data for this period": "No data for this period", + "{n} alert": "{n} alert", + "Out of date": "Out of date", + "No containers or virtual machines to show": "No containers or virtual machines to show" } diff --git a/src/assets/lang/fr_FR.json b/src/assets/lang/fr_FR.json index 355e3a8a..bebb7683 100644 --- a/src/assets/lang/fr_FR.json +++ b/src/assets/lang/fr_FR.json @@ -2428,5 +2428,8 @@ "Disk": "Disque", "Processes": "Processus", "Virtual CPUs": "Processeurs virtuels", - "No data for this period": "Aucune donnée pour cette période" + "No data for this period": "Aucune donnée pour cette période", + "{n} alert": "{n} alerte", + "Out of date": "Pas à jour", + "No containers or virtual machines to show": "Aucun conteneur ni machine virtuelle à afficher" } diff --git a/src/components/Stats/StatsPage.spec.js b/src/components/Stats/StatsPage.spec.js index 7bb27df6..661ca05d 100644 --- a/src/components/Stats/StatsPage.spec.js +++ b/src/components/Stats/StatsPage.spec.js @@ -1,4 +1,6 @@ // @vitest-environment happy-dom +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import Buefy from 'buefy' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -203,6 +205,84 @@ describe('the list of the Statistics page', () => { expect(select.value).toBe('app_management|app:jellyfin:web:1') }) + it('says so from the second failed refresh in a row, keeps the last items, and clears it on a success', async () => { + await mountPage() + serve({ ...defaults(), app_management: failure(401) }) + await wrapper.vm.loadAll() + await flushPromises() + expect(document.querySelector('.stats-column-error')).toBeNull() + serve({ ...defaults(), app_management: failure(502) }) + await wrapper.vm.loadAll() + await flushPromises() + expect(document.querySelector('.stats-column-error').textContent).toContain('Containers did not answer.') + expect(document.querySelector('.stats-column-error button').textContent).toBe('Retry') + expect(names()).toContain('Jellyfin') + expect(document.querySelector('.stats-list .stats-item.is-stale')).not.toBeNull() + serve(defaults()) + await wrapper.vm.loadAll() + await flushPromises() + expect(document.querySelector('.stats-column-error')).toBeNull() + expect(document.querySelector('.stats-item.is-stale')).toBeNull() + }) + + it('restarts the count of failures after a success', async () => { + await mountPage() + for (const answer of [failure(502), defaults().app_management, failure(502)]) { + serve({ ...defaults(), app_management: answer }) + await wrapper.vm.loadAll() + await flushPromises() + } + expect(document.querySelector('.stats-column-error')).toBeNull() + }) + + it('selects the initial item as soon as its column is there, and by source|id too', async () => { + const slow = deferred() + serve({ ...defaults(), virt_management: slow.promise }) + await mountPage({ initialItem: 'virt_management|vm-1' }) + expect(pressed()).toEqual(['Jellyfin']) + slow.resolve(body([vm])) + await flushPromises() + expect(pressed()).toEqual(['Debian']) + }) + + it('does not take the selection back from a person who chose while the column was loading', async () => { + const slow = deferred() + serve({ ...defaults(), virt_management: slow.promise }) + await mountPage({ initialItem: 'vm-1' }) + document.querySelectorAll('.stats-list button')[1].click() + slow.resolve(body([vm])) + await flushPromises() + expect(pressed()).toEqual(['Plex']) + }) + + it('says 1 alert in the singular', async () => { + serve({ ...defaults(), app_management: body([{ ...jelly, alerts: 1 }, { ...plex, alerts: 3 }]) }) + await mountPage() + const badges = [...document.querySelectorAll('.stats-item-alerts')].map(b => b.textContent) + expect(badges).toEqual(['1 alert', '3 alerts']) + }) + + it('says so in the main pane when there is nothing to show', async () => { + serve({ app_management: body([]), virt_management: body([]), core: body([]) }) + await mountPage() + expect(document.querySelector('.stats-empty').textContent).toBe('No containers or virtual machines to show') + }) + + it('cuts the detail at 200 code points, not 200 UTF-16 units', async () => { + serve({ ...defaults(), app_management: body([anItem({ detail: '😀'.repeat(300) })]) }) + await mountPage() + const text = document.querySelector('.stats-detail').textContent + expect(Array.from(text)).toHaveLength(200) + expect(text).toBe('😀'.repeat(200)) + }) + + it('leaves the focus ring of the period buttons visible', () => { + const source = readFileSync(join(import.meta.dirname, 'StatsPage.vue'), 'utf8') + const block = source.slice(source.indexOf('.stats-ranges {'), source.indexOf('.stats-series-error,')) + expect(block).not.toContain('overflow') + expect(block).toContain('focus-visible') + }) + it('takes the focus on the heading', async () => { await mountPage() expect(document.activeElement).toBe(wrapper.vm.$refs.title) @@ -411,6 +491,24 @@ describe('the header, the periods and the figures', () => { expect(document.querySelector('.stats-name').textContent).toBe('Plex') }) + it('forces at most one list refresh per 30 s for an item the series reports gone while the list still has it', async () => { + let now = 1000000 + vi.spyOn(Date, 'now').mockImplementation(() => now) + statsApi.series.mockRejectedValue(failure(404)) + await open() + const forced = vi.spyOn(wrapper.vm.listPoller, 'now') + for (let i = 0; i < 5; i++) { + now += 5000 + await tick(5000) + } + expect(forced).not.toHaveBeenCalled() + expect(document.querySelector('.stats-no-data').textContent).toBe('No data for this period') + now += 30000 + await tick(5000) + expect(forced).toHaveBeenCalledTimes(1) + Date.now.mockRestore() + }) + it('shows a retryable error and keeps the shell', async () => { statsApi.series.mockRejectedValue(failure(500)) await open() diff --git a/src/components/Stats/StatsPage.vue b/src/components/Stats/StatsPage.vue index 14b6a7bc..9e330921 100644 --- a/src/components/Stats/StatsPage.vue +++ b/src/components/Stats/StatsPage.vue @@ -21,15 +21,16 @@
@@ -51,6 +52,7 @@
+

{{ $t('No containers or virtual machines to show') }}

@@ -107,6 +123,7 @@ + + diff --git a/src/components/Stats/StatsPage.spec.js b/src/components/Stats/StatsPage.spec.js index 42f5f4c0..688531cd 100644 --- a/src/components/Stats/StatsPage.spec.js +++ b/src/components/Stats/StatsPage.spec.js @@ -4,7 +4,8 @@ import { join } from 'node:path' import Buefy from 'buefy' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { HOSTILE, aSeries, anItem, makePoints } from './__fixtures__/contract' +import { HOSTILE, aSeries, anAlerts, anItem, makePoints } from './__fixtures__/contract' +import { openStatistics } from './entry' import StatsPage from './StatsPage.vue' import { hasVirtualMachines } from '@/components/VirtualMachines/entry' import { statsApi } from '@/service/stats' @@ -656,3 +657,169 @@ describe('the chart cards of the page', () => { expect((min + max) / 2).toBe(middle) }) }) + +describe('the alerts of the page', () => { + const since = '2026-10-20T08:00:00Z' + const active = [{ key: 'cpu_high', since, value: 93.4 }, { key: 'mem_high', since, value: 95 }] + let hidden + beforeEach(() => { + document.body.innerHTML = '' + vi.clearAllMocks() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + hidden = false + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }) + hasVirtualMachines.mockResolvedValue(true) + statsApi.series.mockResolvedValue(seriesWith({ 'cpu/total': 38 })) + statsApi.alerts.mockResolvedValue(anAlerts({ active })) + serve(defaults()) + }) + afterEach(() => { + wrapper?.unmount() + wrapper = null + delete document.hidden + vi.useRealTimers() + }) + + const tick = ms => vi.advanceTimersByTimeAsync(ms) + async function open(props = {}) { + wrapper = mount(StatsPage, { props, attachTo: document.body, global: { plugins: [Buefy, i18n] } }) + await tick(0) + return wrapper + } + const lines = () => [...document.querySelectorAll('.stats-alerts p')].map(p => p.textContent) + async function openDialog() { + const button = [...document.querySelectorAll('.stats-head button')].find(b => b.textContent.trim() === 'Alert settings') + button.focus() + button.click() + await tick(0) + return button + } + + it('lists the active alerts of the item as text, with a date', async () => { + await open() + expect(statsApi.alerts).toHaveBeenCalledWith('app_management', 'app:jellyfin:web:1') + expect(document.querySelector('.stats-alerts').getAttribute('role')).toBe('status') + expect(lines()).toHaveLength(2) + expect(lines()[0]).toMatch(/^Processor at 93 % since .*2026/) + expect(lines()[1]).toMatch(/^Memory at 95 % since /) + }) + + it('shows nothing when no alert is active', async () => { + statsApi.alerts.mockResolvedValue(anAlerts()) + await open() + expect(document.querySelector('.stats-alerts')).toBeNull() + expect(document.querySelector('.stats-alerts-error')).toBeNull() + }) + + it('says what it can of an alert without a usable value, the crash of a VM, and one it does not know', async () => { + statsApi.alerts.mockResolvedValue(anAlerts({ active: [ + { key: 'cpu_high', since, value: 'NaN' }, + { key: 'mem_high', since }, + { key: 'vm_crashed', since }, + { key: HOSTILE, since, value: 1 }, + ] })) + await open() + expect(lines()[0]).toMatch(/^Processor high since /) + expect(lines()[1]).toMatch(/^Memory high since /) + expect(lines()[2]).toMatch(/^The virtual machine crashed since /) + expect(lines()[3]).toContain(HOSTILE) + expect(document.querySelector('.stats-alerts img')).toBeNull() + }) + + it('reads the alerts again when the count of the list changes, and not otherwise', async () => { + await open() + expect(statsApi.alerts).toHaveBeenCalledTimes(1) + await tick(5000) + expect(statsApi.alerts).toHaveBeenCalledTimes(1) + serve({ ...defaults(), app_management: body([anItem({ alerts: 2 }), plex]) }) + await tick(5000) + expect(statsApi.alerts).toHaveBeenCalledTimes(2) + }) + + it('reads them for the new item, and keeps the answer of the old one out', async () => { + const first = deferred() + statsApi.alerts.mockImplementation((source, id) => id === 'app:jellyfin:web:1' ? first.promise : Promise.resolve(anAlerts())) + await open() + document.querySelectorAll('.stats-list button')[1].click() + await tick(0) + first.resolve(anAlerts({ active })) + await tick(0) + expect(document.querySelector('.stats-name').textContent).toBe('Plex') + expect(document.querySelector('.stats-alerts')).toBeNull() + }) + + it('only says the alerts could not be read, with a Retry that reads them again', async () => { + statsApi.alerts.mockRejectedValueOnce(failure(500)) + await open() + expect(document.querySelector('.stats-alerts-error span').textContent).toBe('Alerts could not be read.') + expect(document.querySelector('.stats-series-error')).toBeNull() + document.querySelector('.stats-alerts-error button').click() + await tick(0) + expect(document.querySelector('.stats-alerts-error')).toBeNull() + expect(lines()).toHaveLength(2) + }) + + it('shows the alerts the save answered', async () => { + statsApi.setAlerts.mockResolvedValue(anAlerts({ active: [{ key: 'mem_high', since, value: 91 }] })) + await open() + await openDialog() + document.querySelector('.stats-dialog-actions .is-primary').click() + await tick(0) + expect(document.querySelector('.stats-dialog')).toBeNull() + expect(lines()).toHaveLength(1) + expect(lines()[0]).toMatch(/^Memory at 91 %/) + }) + + it('opens the settings from a button of the header, with the focus on the dialog heading', async () => { + await open() + await openDialog() + expect(document.querySelector('.stats-dialog[role="dialog"]')).not.toBeNull() + expect(document.activeElement).toBe(document.querySelector('.stats-dialog-title')) + }) + + it('gives the focus back to the button when the dialog closes', async () => { + await open() + const button = await openDialog() + document.querySelector('.stats-dialog-actions button').click() + await tick(0) + expect(document.querySelector('.stats-dialog')).toBeNull() + expect(document.activeElement).toBe(button) + }) + + it('gives the focus to the page heading when the button is gone', async () => { + await open() + await openDialog() + document.querySelector('.stats-head button').remove() + document.querySelector('.stats-dialog-actions button').click() + await tick(0) + expect(document.activeElement).toBe(document.querySelector('.stats-page h3')) + }) + + describe('opened the way the app grid opens it', () => { + let host + const isShown = (selector) => { + const modal = document.querySelector(selector)?.closest('.modal') + return Boolean(modal) && modal.style.display !== 'none' + } + const escape = () => document.activeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true })) + afterEach(() => host?.unmount()) + + it('closes the dialog at the first Escape and the page at the second', async () => { + vi.useRealTimers() + host = mount({ render: () => null }, { attachTo: document.body, global: { plugins: [Buefy, i18n] } }) + openStatistics(host.vm.$buefy, { appLaunchInIframe: true, appLaunchExceptions: [] }) + await vi.waitFor(() => expect(isShown('.stats-page')).toBe(true)) + await flushPromises() + await vi.waitFor(() => expect(document.querySelector('.stats-head')).not.toBeNull()) + const button = [...document.querySelectorAll('.stats-head button')].find(b => b.textContent.trim() === 'Alert settings') + button.click() + await vi.waitFor(() => expect(document.querySelector('.stats-dialog')).not.toBeNull()) + escape() + await flushPromises() + expect(document.querySelector('.stats-dialog')).toBeNull() + expect(isShown('.stats-page')).toBe(true) + escape() + await vi.waitFor(() => expect(isShown('.stats-page')).toBe(false)) + }) + }) +}) diff --git a/src/components/Stats/StatsPage.vue b/src/components/Stats/StatsPage.vue index 97f41899..367440bd 100644 --- a/src/components/Stats/StatsPage.vue +++ b/src/components/Stats/StatsPage.vue @@ -60,10 +60,19 @@ {{ kindWord(selected.kind) }} {{ stateWord(selected.state) }} {{ sinceText }} +

{{ detailText }}

+
+

{{ line.text }}

+
+
+ {{ $t('Alerts could not be read.') }} + +
+
+
+ + diff --git a/src/components/Stats/entry.js b/src/components/Stats/entry.js index 86eb4493..2204f2c9 100644 --- a/src/components/Stats/entry.js +++ b/src/components/Stats/entry.js @@ -12,6 +12,16 @@ export const STATS_APP_NAME = 'Statistics' // Opens the page in a modal, or on a tab of its own when the App launching setting sends apps, or this one, to a new tab. // `item` is the id of the item to show first, or 'source|id' when the caller knows the source too. export function openStatistics(buefy, state, { item } = {}) { + // a window already on screen is the one to use: it shows the item asked for and takes the focus + const open = document.querySelector('.stats-modal.is-active .stats-page') + if (open && !builtinOpensInNewTab(STATS_LAUNCH_ID, state)) { + if (item) { + window.dispatchEvent(new CustomEvent('stats-open-item', { detail: item })) + } + open.querySelector('h3')?.focus({ preventScroll: true }) + return null + } + if (builtinOpensInNewTab(STATS_LAUNCH_ID, state)) { const query = item ? `?item=${encodeURIComponent(item)}` : '' window.open(`${window.location.pathname}#/stats${query}`, '_blank') diff --git a/src/components/Stats/entry.spec.js b/src/components/Stats/entry.spec.js index 7276c6ed..8fc65b7e 100644 --- a/src/components/Stats/entry.spec.js +++ b/src/components/Stats/entry.spec.js @@ -69,3 +69,22 @@ describe('the Statistics entry', () => { expect(win).toHaveBeenCalledTimes(1) }) }) + +describe('the Statistics window when it is already open', () => { + beforeEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + it('is reused: no second modal, the heading is focused, the item is offered to the page', async () => { + const { openStatistics } = await import('./entry.js') + const { buefy, open } = fakeBuefy() + const heard = vi.fn() + window.addEventListener('stats-open-item', heard) + expect(openStatistics(buefy, { appLaunchInIframe: true, appLaunchExceptions: [] }, { item: 'vm:abc' })).toBeNull() + window.removeEventListener('stats-open-item', heard) + expect(open).not.toHaveBeenCalled() + expect(document.activeElement).toBe(document.querySelector('.stats-page h3')) + expect(heard.mock.calls[0][0].detail).toBe('vm:abc') + }) +})