diff --git a/mock/stats-stub/handlers.js b/mock/stats-stub/handlers.js
new file mode 100644
index 00000000..8b25fa8d
--- /dev/null
+++ b/mock/stats-stub/handlers.js
@@ -0,0 +1,250 @@
+// A stub of the three statistics services, built from the contract fixtures: a pure `handle(request, options)`.
+//
+// request: { method, path, query, body } -> { status, body }
+// options: {
+// envelope: 'real' | 'bare' 'real' wraps the core in {success, message, data}, the two v2 services answer bare;
+// 'bare' answers all three bare (a deliberate violation: the core column must fail)
+// now: seconds the clock; the series end at it, so a live chart moves
+// alerts: boolean one container has 2 active alerts
+// down: [source] sources whose list answers 503
+// rules: Map the rules PUT so far, by item id (optional; without it a PUT is only validated and echoed)
+// }
+import { CONTAINER_SERIES, HOST_SERIES, VM_SERIES, anAlerts, anItem } from '../../src/components/Stats/__fixtures__/contract.js'
+
+const PREFIXES = {
+ '/v2/app_management/stats': 'app_management',
+ '/v2/virt_management/stats': 'virt_management',
+ '/v1/sys/stats': 'core',
+}
+
+// range -> [step in seconds, number of points]
+const RANGES = { 'live': [5, 180], '15m': [60, 15], '1h': [60, 60], '24h': [300, 288], '7d': [1800, 336] }
+
+const BYTES_PER_GIB = 1073741824
+const CORE_ALERTING = 'app:immich:server:1'
+
+const ITEMS = {
+ app_management: [
+ anItem({ id: 'app:jellyfin:web:1', name: 'Jellyfin / web', app: 'Jellyfin', seed: 1 }),
+ anItem({ id: 'app:plex:web:1', name: 'Plex / web', app: 'Plex', detail: 'plexinc/pms-docker:latest', seed: 2 }),
+ anItem({ id: CORE_ALERTING, name: 'Immich / server', app: 'Immich', detail: 'immich-app/immich-server:release', seed: 3 }),
+ anItem({ id: 'container:postgres', name: 'postgres', kind: 'container', group: 'Autres conteneurs', app: undefined, detail: 'postgres:16', seed: 4 }),
+ ],
+ virt_management: [
+ anItem({ id: 'vm:11111111-aaaa', name: 'Debian', kind: 'vm', group: 'Machines virtuelles', app: undefined, detail: 'Debian 12', memory_limit: 2 * BYTES_PER_GIB, seed: 5 }),
+ anItem({ id: 'vm:22222222-bbbb', name: 'Windows', kind: 'vm', group: 'Machines virtuelles', app: undefined, detail: 'Windows 11', memory_limit: 4 * BYTES_PER_GIB, seed: 6 }),
+ ],
+ core: [
+ anItem({ id: 'host', name: 'stagevet-box', kind: 'host', group: 'Machine', app: undefined, detail: null, memory_limit: 8 * BYTES_PER_GIB, seed: 7 }),
+ ],
+}
+
+const OTHER_SERIES = { app_management: CONTAINER_SERIES, virt_management: VM_SERIES, core: HOST_SERIES }
+
+const wave = (t, seed, period) => (Math.sin((t / period) + seed) + 1) / 2
+
+// The value of one series at second t, as a number in the unit's natural range
+function valueAt(chart, name, unit, t, seed) {
+ const key = `${chart}/${name}`
+ if (key === 'cpu/vcpus') {
+ return seed === 7 ? 4 : 2
+ }
+ if (key === 'memory/limit') {
+ return seed === 7 ? 8 * BYTES_PER_GIB : BYTES_PER_GIB
+ }
+ if (key === 'memory/assigned') {
+ return (seed === 5 ? 2 : 4) * BYTES_PER_GIB
+ }
+ if (chart === 'fs') {
+ return name.endsWith(':/') ? 35 : 60
+ }
+ if (key === 'health/ok') {
+ return 1
+ }
+ if (key === 'restarts/count') {
+ return 0
+ }
+ const w = wave(t, seed, 30 + seed * 7)
+ switch (unit) {
+ case 'percent': return 5 + w * 60
+ case 'bytes': return (0.1 + w * 0.5) * BYTES_PER_GIB
+ case 'bytes_per_second': return w * 5000000
+ case 'packets_per_second': return w * 3
+ case 'ops_per_second': return w * 400
+ default: return Math.round(10 + w * 30)
+ }
+}
+
+const round = n => Math.round(n * 1000) / 1000
+
+function pointsOf({ chart, name, unit }, { from, step, count }, seed, gapAt) {
+ return Array.from({ length: count }, (_, i) => {
+ const t = from + i * step
+ if (gapAt.includes(i)) {
+ return [t, null, null, null]
+ }
+ const avg = round(valueAt(chart, name, unit, t, seed))
+ return [t, avg, round(avg * 0.9), round(avg * 1.1)]
+ })
+}
+
+const reply = (status, body) => ({ status, body })
+const fail = (status, code) => reply(status, { message: code })
+
+function find(id) {
+ for (const source of Object.keys(ITEMS)) {
+ const item = ITEMS[source].find(candidate => candidate.id === id)
+ if (item) {
+ return { source, item }
+ }
+ }
+ return null
+}
+
+function listOf(source, now, alerts) {
+ const cpu = item => round(valueAt('cpu', 'total', 'percent', now, item.seed))
+ return {
+ items: ITEMS[source].map(({ seed, ...item }) => ({
+ ...item,
+ // the fixtures give every key; a null detail stays null
+ cpu_percent: cpu({ seed }),
+ alerts: alerts && item.id === CORE_ALERTING ? 2 : 0,
+ })),
+ notes: [],
+ }
+}
+
+function seriesOf(source, item, range, now) {
+ const [step, count] = RANGES[range]
+ const to = Math.floor(now / step) * step
+ const frame = { from: to - (count - 1) * step, step, count }
+ // the 7-day chart has a hole in the middle: the machine was off for a while
+ const gapAt = range === '7d' ? Array.from({ length: 12 }, (_, i) => Math.floor(count / 2) + i) : []
+ const noMemoryUsed = item.id === 'vm:22222222-bbbb'
+ const noCpuLimit = item.id === 'app:plex:web:1'
+ const list = OTHER_SERIES[source].filter(s => !(noMemoryUsed && s.chart === 'memory' && s.name === 'used'))
+ const notes = []
+ if (noMemoryUsed) {
+ notes.push('vm_memory_used_unavailable')
+ }
+ if (noCpuLimit) {
+ notes.push('no_cpu_limit')
+ }
+ const vcpus = source === 'app_management' ? (noCpuLimit ? 4 : 2) : (item.id === 'host' ? 4 : 2)
+ return {
+ id: item.id,
+ range,
+ step_seconds: step,
+ from: frame.from,
+ to,
+ scale: { cpu: 'percent_of_one_core', cpu_allocation: vcpus, memory_limit: item.memory_limit ?? null },
+ series: list.map(s => ({ chart: s.chart, name: s.name, unit: s.unit, points: pointsOf(s, frame, item.seed, gapAt) })),
+ notes,
+ }
+}
+
+const KEYS = ['cpu_high', 'mem_high']
+const inRange = (n, min, max) => typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
+
+// the first refused field of a PUT body as 'rules[i].field', 'rules' for a body that has no list
+function refusal(body) {
+ if (!body || !Array.isArray(body.rules)) {
+ return 'rules'
+ }
+ for (let i = 0; i < body.rules.length; i++) {
+ const rule = body.rules[i]
+ if (!rule || typeof rule !== 'object' || !KEYS.includes(rule.key)) {
+ return `rules[${i}].key`
+ }
+ if (typeof rule.enabled !== 'boolean') {
+ return `rules[${i}].enabled`
+ }
+ if (!inRange(rule.threshold, 1, 100)) {
+ return `rules[${i}].threshold`
+ }
+ if (!inRange(rule.minutes, 1, 120)) {
+ return `rules[${i}].minutes`
+ }
+ }
+ return ''
+}
+
+function alertsOf(item, options, now) {
+ const stored = options.rules?.get(item.id)
+ const base = anAlerts()
+ const firing = options.alerts && item.id === CORE_ALERTING
+ return {
+ rules: stored || base.rules,
+ active: firing
+ ? [
+ { key: 'cpu_high', since: new Date((now - 600) * 1000).toISOString(), value: 93 },
+ { key: 'mem_high', since: new Date((now - 300) * 1000).toISOString(), value: 95 },
+ ]
+ : [],
+ }
+}
+
+function route(method, source, rest, query, body, options, now) {
+ const segments = rest.split('/').filter(Boolean)
+ if (segments.length === 0) {
+ if (method !== 'GET') {
+ return fail(405, 'method_not_allowed')
+ }
+ return options.down?.includes(source) ? fail(503, 'stats_unavailable') : reply(200, listOf(source, now, options.alerts))
+ }
+ let id
+ try {
+ id = decodeURIComponent(segments[0])
+ } catch {
+ return fail(404, 'unknown_item')
+ }
+ if (id === 'broken') {
+ return fail(503, 'stats_unavailable')
+ }
+ const found = find(id)
+ if (!found || found.source !== source || segments.length > 2) {
+ return fail(404, 'unknown_item')
+ }
+ if (segments.length === 1) {
+ if (method !== 'GET') {
+ return fail(405, 'method_not_allowed')
+ }
+ const range = query?.range ?? 'live'
+ return Object.prototype.hasOwnProperty.call(RANGES, range) ? reply(200, seriesOf(source, found.item, range, now)) : fail(400, 'bad_range')
+ }
+ if (segments[1] !== 'alerts') {
+ return fail(404, 'unknown_item')
+ }
+ if (method === 'GET') {
+ return reply(200, alertsOf(found.item, options, now))
+ }
+ if (method === 'PUT') {
+ const field = refusal(body)
+ if (field) {
+ return fail(400, `bad_rules: ${field}`)
+ }
+ // a PUT updates the keys it sends and leaves the others
+ const merged = new Map(alertsOf(found.item, options, now).rules.map(rule => [rule.key, rule]))
+ body.rules.forEach(rule => merged.set(rule.key, { key: rule.key, enabled: rule.enabled, threshold: rule.threshold, minutes: rule.minutes }))
+ const rules = [...merged.values()]
+ options.rules?.set(found.item.id, rules)
+ return reply(200, { ...alertsOf(found.item, options, now), rules })
+ }
+ return fail(405, 'method_not_allowed')
+}
+
+export function handle({ method = 'GET', path = '', query = {}, body = null } = {}, options = {}) {
+ const prefix = Object.keys(PREFIXES).find(candidate => path === candidate || path.startsWith(`${candidate}/`))
+ if (!prefix) {
+ return fail(404, 'not_found')
+ }
+ const source = PREFIXES[prefix]
+ const now = options.now ?? Date.now() / 1000
+ const answer = route(method.toUpperCase(), source, path.slice(prefix.length), query, body, options, now)
+ // the core's habitual envelope; errors keep their HTTP status and carry the code in `message`
+ if (source === 'core' && (options.envelope ?? 'real') === 'real') {
+ const ok = answer.status < 300
+ return reply(answer.status, ok ? { success: 200, message: 'ok', data: answer.body } : { success: answer.status, message: answer.body.message })
+ }
+ return answer
+}
diff --git a/mock/stats-stub/server.mjs b/mock/stats-stub/server.mjs
new file mode 100644
index 00000000..5e9cb4ad
--- /dev/null
+++ b/mock/stats-stub/server.mjs
@@ -0,0 +1,92 @@
+// A server for the stub of the statistics contract. No dependency.
+//
+// node mock/stats-stub/server.mjs --port 8099 [--envelope real|bare] [--upstream 10.0.0.97:80]
+//
+// Answers /v1/sys/stats, /v2/app_management/stats and /v2/virt_management/stats from handlers.js, with series that end
+// at the current time (a live chart moves). With --upstream, every other request (and the WebSocket upgrade of the
+// message bus) goes to that box; without it, other paths answer 404.
+//
+// GET /__stub/alerts?on=1 puts 2 active alerts on one container (on=0 takes them off)
+import { Buffer } from 'node:buffer'
+import { createServer, request } from 'node:http'
+import { connect } from 'node:net'
+import process from 'node:process'
+import { handle } from './handlers.js'
+
+const args = process.argv.slice(2)
+function option(name, fallback) {
+ const at = args.indexOf(`--${name}`)
+ return at >= 0 && args[at + 1] ? args[at + 1] : fallback
+}
+
+const port = Number(option('port', '8099'))
+const envelope = option('envelope', 'real')
+const upstream = option('upstream', '')
+if (!['real', 'bare'].includes(envelope) || !Number.isInteger(port)) {
+ console.error('usage: node server.mjs [--port 8099] [--envelope real|bare] [--upstream host:port]')
+ process.exit(2)
+}
+const [upstreamHost, upstreamPort = '80'] = upstream.split(':')
+
+const options = { envelope, alerts: false, down: [], rules: new Map() }
+const STATS = /^\/v[12]\/(?:sys|app_management|virt_management)\/stats(?:\/|$)/
+
+function send(res, status, body) {
+ res.writeHead(status, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(body))
+}
+
+function proxy(req, res) {
+ const forward = request({ host: upstreamHost, port: upstreamPort, path: req.url, method: req.method, headers: req.headers }, (answer) => {
+ res.writeHead(answer.statusCode, answer.headers)
+ answer.pipe(res)
+ })
+ forward.on('error', () => send(res, 502, { message: 'upstream_unreachable' }))
+ req.pipe(forward)
+}
+
+const server = createServer((req, res) => {
+ const url = new URL(req.url, 'http://stub.local')
+ if (url.pathname === '/__stub/alerts') {
+ options.alerts = url.searchParams.get('on') === '1'
+ return send(res, 200, { alerts: options.alerts })
+ }
+ if (!STATS.test(url.pathname)) {
+ return upstream ? proxy(req, res) : send(res, 404, { message: 'not_found' })
+ }
+ const chunks = []
+ req.on('data', chunk => chunks.push(chunk))
+ req.on('end', () => {
+ let body = null
+ try {
+ body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null
+ } catch {
+ return send(res, 400, { message: 'bad_rules: rules' })
+ }
+ const { status, body: answer } = handle({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), body }, options)
+ send(res, status, answer)
+ })
+})
+
+// the WebSocket of the message bus: a plain tunnel to the upstream box
+server.on('upgrade', (req, socket, head) => {
+ if (!upstream) {
+ socket.destroy()
+ return
+ }
+ const tunnel = connect(Number(upstreamPort), upstreamHost, () => {
+ const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`]
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
+ lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`)
+ }
+ tunnel.write(`${lines.join('\r\n')}\r\n\r\n`)
+ tunnel.write(head)
+ socket.pipe(tunnel).pipe(socket)
+ })
+ tunnel.on('error', () => socket.destroy())
+ socket.on('error', () => tunnel.destroy())
+})
+
+server.listen(port, () => {
+ console.log(`stats stub on http://127.0.0.1:${port} (envelope ${envelope}${upstream ? `, upstream ${upstream}` : ', no upstream'})`)
+})
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..34728209 100644
--- a/src/assets/lang/en_US.json
+++ b/src/assets/lang/en_US.json
@@ -2403,5 +2403,109 @@
"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",
+ "Statistics items": "Statistics items",
+ "{n} alerts": "{n} alerts",
+ "Sign in again to read {name}.": "Sign in again to read {name}.",
+ "{name} did not answer.": "{name} did not answer.",
+ "{name} could not be read.": "{name} could not be read.",
+ "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.",
+ "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",
+ "{n} alert": "{n} alert",
+ "Out of date": "Out of date",
+ "No containers or virtual machines to show": "No containers or virtual machines to show",
+ "Series": "Series",
+ "Last": "Last",
+ "Min": "Min",
+ "Average": "Average",
+ "Max": "Max",
+ "Show as a table": "Show as a table",
+ "Hide the table": "Hide the table",
+ "Reset zoom": "Reset zoom",
+ "Alert settings": "Alert settings",
+ "Loading...": "Loading...",
+ "The settings could not be read.": "The settings could not be read.",
+ "Rule {key}": "Rule {key}",
+ "Threshold (%)": "Threshold (%)",
+ "Duration (minutes)": "Duration (minutes)",
+ "From {min} to {max}": "From {min} to {max}",
+ "These values were refused.": "These values were refused.",
+ "The settings could not be saved. Try again.": "The settings could not be saved. Try again.",
+ "Processor at {value} % since {since}": "Processor at {value} % since {since}",
+ "Memory at {value} % since {since}": "Memory at {value} % since {since}",
+ "Processor high since {since}": "Processor high since {since}",
+ "Memory high since {since}": "Memory high since {since}",
+ "The virtual machine crashed since {since}": "The virtual machine crashed since {since}",
+ "Alert {key} since {since}": "Alert {key} since {since}",
+ "Alerts could not be read.": "Alerts could not be read.",
+ "Active alerts": "Active alerts",
+ "This item is gone": "This item is gone",
+ "Processor of {name} at {value} % since {since}": "Processor of {name} at {value} % since {since}",
+ "Processor of {name} at {value} %": "Processor of {name} at {value} %",
+ "Processor of {name} high since {since}": "Processor of {name} high since {since}",
+ "Processor of {name} is high": "Processor of {name} is high",
+ "Processor of {name} is back to normal": "Processor of {name} is back to normal",
+ "Memory of {name} at {value} % since {since}": "Memory of {name} at {value} % since {since}",
+ "Memory of {name} at {value} %": "Memory of {name} at {value} %",
+ "Memory of {name} high since {since}": "Memory of {name} high since {since}",
+ "Memory of {name} is high": "Memory of {name} is high",
+ "Memory of {name} is back to normal": "Memory of {name} is back to normal",
+ "The virtual machine {name} crashed": "The virtual machine {name} crashed",
+ "The virtual machine {name} is running again": "The virtual machine {name} is running again",
+ "Open statistics": "Open statistics",
+ "A container or a virtual machine uses too much processor or memory, or a virtual machine crashes": "A container or a virtual machine uses too much processor or memory, or a virtual machine crashes",
+ "Statistics, {count} active alert": "Statistics, {count} active alert",
+ "Statistics, {count} active alerts": "Statistics, {count} active alerts",
+ "Waiting for disk": "Waiting for disk",
+ "Throttled": "Throttled",
+ "Processor limit": "Processor limit",
+ "Resident": "Resident",
+ "Cache": "Cache",
+ "Swap": "Swap",
+ "Memory limit": "Memory limit",
+ "Assigned": "Assigned",
+ "Dropped packets": "Dropped packets",
+ "Dropped": "Dropped",
+ "Read": "Read",
+ "Write": "Write",
+ "Disk operations": "Disk operations",
+ "Read operations": "Read operations",
+ "Write operations": "Write operations",
+ "Free space": "Free space",
+ "Open files": "Open files",
+ "Restarts": "Restarts",
+ "Capacity of the virtual processors": "Capacity of the virtual processors",
+ "The memory used inside the virtual machine is not reported.": "The memory used inside the virtual machine is not reported.",
+ "No processor limit is set: the cores of the machine are the reference.": "No processor limit is set: the cores of the machine are the reference.",
+ "No memory limit is set.": "No memory limit is set.",
+ "This container no longer exists: only its history is shown.": "This container no longer exists: only its history is shown.",
+ "{label} (max)": "{label} (max)",
+ "% of one core": "% of one core",
+ "{n} cores allocated": "{n} cores allocated",
+ "no limit set": "no limit set",
+ "on {size} allowed": "on {size} allowed",
+ "{days} d": "{days} d",
+ "{hours} h {minutes} min": "{hours} h {minutes} min",
+ "{minutes} min": "{minutes} min",
+ "{seconds} s": "{seconds} s",
+ "Exited with an error": "Exited with an error",
+ "The settings are applied but could not be kept for good.": "The settings are applied but could not be kept for good."
}
diff --git a/src/assets/lang/fr_FR.json b/src/assets/lang/fr_FR.json
index 389accc1..629cd4db 100644
--- a/src/assets/lang/fr_FR.json
+++ b/src/assets/lang/fr_FR.json
@@ -2403,5 +2403,109 @@
"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",
+ "Statistics items": "Éléments des statistiques",
+ "{n} alerts": "{n} alertes",
+ "Sign in again to read {name}.": "Reconnectez-vous pour lire : {name}.",
+ "{name} did not answer.": "{name} ne répond pas.",
+ "{name} could not be read.": "{name} n'a pas pu être lu.",
+ "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.",
+ "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",
+ "{n} alert": "{n} alerte",
+ "Out of date": "Pas à jour",
+ "No containers or virtual machines to show": "Aucun conteneur ni machine virtuelle à afficher",
+ "Series": "Série",
+ "Last": "Dernière",
+ "Min": "Min",
+ "Average": "Moyenne",
+ "Max": "Max",
+ "Show as a table": "Afficher sous forme de tableau",
+ "Hide the table": "Masquer le tableau",
+ "Reset zoom": "Réinitialiser le zoom",
+ "Alert settings": "Réglages des alertes",
+ "Loading...": "Chargement...",
+ "The settings could not be read.": "Les réglages n'ont pas pu être lus.",
+ "Rule {key}": "Règle {key}",
+ "Threshold (%)": "Seuil (%)",
+ "Duration (minutes)": "Durée (minutes)",
+ "From {min} to {max}": "De {min} à {max}",
+ "These values were refused.": "Ces valeurs ont été refusées.",
+ "The settings could not be saved. Try again.": "Les réglages n'ont pas pu être enregistrés. Réessayez.",
+ "Processor at {value} % since {since}": "Processeur à {value} % depuis {since}",
+ "Memory at {value} % since {since}": "Mémoire à {value} % depuis {since}",
+ "Processor high since {since}": "Processeur élevé depuis {since}",
+ "Memory high since {since}": "Mémoire élevée depuis {since}",
+ "The virtual machine crashed since {since}": "La machine virtuelle a planté depuis {since}",
+ "Alert {key} since {since}": "Alerte {key} depuis {since}",
+ "Alerts could not be read.": "Les alertes n'ont pas pu être lues.",
+ "Active alerts": "Alertes en cours",
+ "This item is gone": "Cet élément n'existe plus",
+ "Processor of {name} at {value} % since {since}": "Processeur de {name} à {value} % depuis {since}",
+ "Processor of {name} at {value} %": "Processeur de {name} à {value} %",
+ "Processor of {name} high since {since}": "Processeur de {name} élevé depuis {since}",
+ "Processor of {name} is high": "Le processeur de {name} est élevé",
+ "Processor of {name} is back to normal": "Le processeur de {name} est revenu à la normale",
+ "Memory of {name} at {value} % since {since}": "Mémoire de {name} à {value} % depuis {since}",
+ "Memory of {name} at {value} %": "Mémoire de {name} à {value} %",
+ "Memory of {name} high since {since}": "Mémoire de {name} élevée depuis {since}",
+ "Memory of {name} is high": "La mémoire de {name} est élevée",
+ "Memory of {name} is back to normal": "La mémoire de {name} est revenue à la normale",
+ "The virtual machine {name} crashed": "La machine virtuelle {name} a planté",
+ "The virtual machine {name} is running again": "La machine virtuelle {name} fonctionne de nouveau",
+ "Open statistics": "Ouvrir les statistiques",
+ "A container or a virtual machine uses too much processor or memory, or a virtual machine crashes": "Un conteneur ou une machine virtuelle utilise trop de processeur ou de mémoire, ou une machine virtuelle plante",
+ "Statistics, {count} active alert": "Statistiques, {count} alerte en cours",
+ "Statistics, {count} active alerts": "Statistiques, {count} alertes en cours",
+ "Waiting for disk": "Attente du disque",
+ "Throttled": "Bridé",
+ "Processor limit": "Limite de processeur",
+ "Resident": "Résidente",
+ "Cache": "Cache",
+ "Swap": "Swap",
+ "Memory limit": "Limite de mémoire",
+ "Assigned": "Attribuée",
+ "Dropped packets": "Paquets perdus",
+ "Dropped": "Perdus",
+ "Read": "Lecture",
+ "Write": "Écriture",
+ "Disk operations": "Opérations disque",
+ "Read operations": "Opérations de lecture",
+ "Write operations": "Opérations d'écriture",
+ "Free space": "Espace libre",
+ "Open files": "Fichiers ouverts",
+ "Restarts": "Redémarrages",
+ "Capacity of the virtual processors": "Capacité des processeurs virtuels",
+ "The memory used inside the virtual machine is not reported.": "La mémoire utilisée dans la machine virtuelle n'est pas rapportée.",
+ "No processor limit is set: the cores of the machine are the reference.": "Aucune limite de processeur n'est définie : les cœurs de la machine servent de référence.",
+ "No memory limit is set.": "Aucune limite de mémoire n'est définie.",
+ "This container no longer exists: only its history is shown.": "Ce conteneur n'existe plus : seul son historique est affiché.",
+ "{label} (max)": "{label} (max)",
+ "% of one core": "% d'un cœur",
+ "{n} cores allocated": "{n} cœurs alloués",
+ "no limit set": "aucune limite",
+ "on {size} allowed": "sur {size} autorisés",
+ "{days} d": "{days} j",
+ "{hours} h {minutes} min": "{hours} h {minutes} min",
+ "{minutes} min": "{minutes} min",
+ "{seconds} s": "{seconds} s",
+ "Exited with an error": "Arrêté avec une erreur",
+ "The settings are applied but could not be kept for good.": "Les réglages sont appliqués, mais n'ont pas pu être conservés durablement."
}
diff --git a/src/components/Apps/AppCard.spec.js b/src/components/Apps/AppCard.spec.js
index c58bada9..80a58f89 100644
--- a/src/components/Apps/AppCard.spec.js
+++ b/src/components/Apps/AppCard.spec.js
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import i18n from '@/plugins/i18n'
import AppCard from '@/components/Apps/AppCard.vue'
import cTooltip from '@/components/basicComponents/tooltip/tooltip.vue'
+import { alertBadge } from '@/components/Stats/alertBadge'
// require.context has no Vite equivalent; a near-empty table makes $t return its key.
// The one entry is the message the update failure needs: $t leaves a MISSING key
@@ -14,6 +15,8 @@ import cTooltip from '@/components/basicComponents/tooltip/tooltip.vue'
vi.mock('@/assets/lang', () => ({
default: {
en_us: {
+ 'Statistics, {count} active alert': 'Statistics, {count} active alert',
+ 'Statistics, {count} active alerts': 'Statistics, {count} active alerts',
'Updating {name} failed: {reason}': 'Updating {name} failed: {reason}',
'{name} now runs the image that was just pulled.': '{name} now runs the image that was just pulled.',
'{name} already runs the newest image, so it was left as it is.': '{name} already runs the newest image, so it was left as it is.',
@@ -493,3 +496,77 @@ describe('a stack CasaOS cannot load', () => {
expect(wrapper.text()).not.toContain('CasaOS cannot read its compose file.')
})
})
+
+// The Statistics card carries the number of active resource alerts over its icon: a number, never a word from a service.
+describe('the Statistics card count badge', () => {
+ const stats = { name: 'Statistics', app_type: 'system', status: 'running', title: { en_us: 'Statistics' } }
+ const badge = wrapper => wrapper.find('.stats-badge')
+ afterEach(() => Object.assign(alertBadge.counts, { app_management: 0, virt_management: 0, core: 0 }))
+
+ it('shows the total, hidden from assistive technology, and names it on the icon', async () => {
+ Object.assign(alertBadge.counts, { app_management: 2, core: 1 })
+ const wrapper = await card(stats)
+
+ expect(badge(wrapper).text()).toBe('3')
+ expect(badge(wrapper).attributes('aria-hidden')).toBe('true')
+ expect(wrapper.find('img').attributes('alt')).toBe('Statistics, 3 active alerts')
+ wrapper.unmount()
+ })
+
+ it('says "alert" for one', async () => {
+ alertBadge.counts.core = 1
+ const wrapper = await card(stats)
+
+ expect(wrapper.find('img').attributes('alt')).toBe('Statistics, 1 active alert')
+ wrapper.unmount()
+ })
+
+ it('shows 99+ above 99', async () => {
+ Object.assign(alertBadge.counts, { app_management: 99, core: 1 })
+ const wrapper = await card(stats)
+
+ expect(badge(wrapper).text()).toBe('99+')
+ wrapper.unmount()
+ })
+
+ it('shows 99 as it is', async () => {
+ alertBadge.counts.app_management = 99
+ const wrapper = await card(stats)
+
+ expect(badge(wrapper).text()).toBe('99')
+ wrapper.unmount()
+ })
+
+ it('is only for the system app, not for an app that is merely called Statistics', async () => {
+ alertBadge.counts.core = 2
+ const wrapper = await card({ ...stats, app_type: 'v1app' })
+
+ expect(badge(wrapper).exists()).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('shows nothing at 0', async () => {
+ const wrapper = await card(stats)
+
+ expect(badge(wrapper).exists()).toBe(false)
+ expect(wrapper.find('img').attributes('alt') || '').not.toContain('active alert')
+ wrapper.unmount()
+ })
+
+ it('follows the count while the card is on screen', async () => {
+ const wrapper = await card(stats)
+ alertBadge.counts.core = 2
+ await nextTick()
+
+ expect(badge(wrapper).text()).toBe('2')
+ wrapper.unmount()
+ })
+
+ it('is drawn on no other card', async () => {
+ alertBadge.counts.core = 5
+ const wrapper = await card({ name: 'Virtual Machines', app_type: 'system', title: { en_us: 'Virtual Machines' } })
+
+ expect(badge(wrapper).exists()).toBe(false)
+ wrapper.unmount()
+ })
+})
diff --git a/src/components/Apps/AppCard.vue b/src/components/Apps/AppCard.vue
index 1b4b0f0e..0d51b062 100644
--- a/src/components/Apps/AppCard.vue
+++ b/src/components/Apps/AppCard.vue
@@ -131,9 +131,11 @@
-
+
+ {{ statsBadge.text }}
+
+
+
+
+
+
+
{{ failure.text }}
+
+
+
{{ note.text }}
+
+
+
+
{{ $t('No containers or virtual machines to show') }}
+
+
+
+
+
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/components/Stats/alertBadge.js b/src/components/Stats/alertBadge.js
new file mode 100644
index 00000000..f57099a2
--- /dev/null
+++ b/src/components/Stats/alertBadge.js
@@ -0,0 +1,147 @@
+// The count of active resource alerts on the Statistics icon. It reads the `alerts` count of the three list routes, once
+// and again (debounced) when an alert event goes by. It must stay light: the app grid imports it, so it imports neither the
+// page nor apexcharts.
+import { reactive } from 'vue'
+import { hasVirtualMachines } from '@/components/VirtualMachines/entry'
+
+// The HTTP client is loaded on the first ask, not at the top: the app grid imports this file, and should not pull in the
+// client and the store with it (the pattern of hasVirtualMachines).
+const listOf = source => import('@/service/stats').then(({ statsApi }) => statsApi.list(source))
+
+const MAX_ITEMS = 500
+const MAX_COUNT = 999
+const AWAY_MS = 60000
+
+const count = n => Math.min(MAX_COUNT, n)
+
+// The sum of the alerts of a list's items; anything that is not a finite number above 0 counts for nothing.
+function sumOf(body) {
+ if (!body || !Array.isArray(body.items)) {
+ return 0
+ }
+ let sum = 0
+ for (const item of body.items.slice(0, MAX_ITEMS)) {
+ const n = item?.alerts
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
+ sum += n
+ }
+ }
+ return count(sum)
+}
+
+export function createAlertBadge({ list = listOf, hasVm = hasVirtualMachines, delayMs = 2000, isHidden = () => document.hidden, now = () => Date.now() } = {}) {
+ const counts = reactive({ app_management: 0, virt_management: 0, core: 0 })
+ let timer = null
+ let flying = false
+ let again = false
+ let waiting = false
+ let generation = 0
+ let hiddenAt = null
+ let watching = false
+
+ // a source that says 404 (module gone) counts 0; any other failure keeps what it counted before
+ const read = source => Promise.resolve().then(() => list(source)).then(sumOf, e => e?.response?.status === 404 ? 0 : counts[source])
+
+ // The tab shown again after more than a minute: the events it missed are not coming back.
+ function onShown() {
+ if (isHidden()) {
+ hiddenAt = now()
+ return
+ }
+ const away = hiddenAt === null ? 0 : now() - hiddenAt
+ hiddenAt = null
+ if (away > AWAY_MS && !waiting) {
+ load()
+ }
+ }
+
+ function onVisible() {
+ if (isHidden()) {
+ return
+ }
+ stopWaiting()
+ load()
+ }
+ function stopWaiting() {
+ if (waiting) {
+ waiting = false
+ document.removeEventListener('visibilitychange', onVisible)
+ }
+ }
+ function wait() {
+ if (!waiting) {
+ waiting = true
+ document.addEventListener('visibilitychange', onVisible)
+ }
+ }
+
+ async function load() {
+ if (!watching) {
+ watching = true
+ hiddenAt = isHidden() ? now() : null
+ document.addEventListener('visibilitychange', onShown)
+ }
+ if (flying) {
+ again = true
+ return
+ }
+ flying = true
+ const mine = generation
+ const virt = Promise.resolve().then(hasVm).catch(() => false).then(there => there ? read('virt_management') : 0)
+ const [app, vm, core] = await Promise.all([read('app_management'), virt, read('core')])
+ if (mine !== generation) {
+ return
+ }
+ flying = false
+ Object.assign(counts, { app_management: app, virt_management: vm, core })
+ if (again) {
+ again = false
+ if (isHidden()) {
+ wait()
+ } else {
+ await load()
+ }
+ }
+ }
+
+ const badge = {
+ counts,
+ total: () => count(counts.app_management + counts.virt_management + counts.core),
+ load,
+
+ // Called for every alert event, whether or not the notification feed kept it.
+ refreshSoon() {
+ if (timer !== null) {
+ return
+ }
+ if (isHidden()) {
+ wait()
+ return
+ }
+ timer = setTimeout(() => {
+ timer = null
+ if (isHidden()) {
+ wait()
+ } else {
+ load()
+ }
+ }, delayMs)
+ },
+
+ stop() {
+ generation++
+ clearTimeout(timer)
+ timer = null
+ flying = false
+ again = false
+ stopWaiting()
+ if (watching) {
+ watching = false
+ document.removeEventListener('visibilitychange', onShown)
+ }
+ },
+ }
+ return badge
+}
+
+export const alertBadge = createAlertBadge()
diff --git a/src/components/Stats/alertBadge.spec.js b/src/components/Stats/alertBadge.spec.js
new file mode 100644
index 00000000..ea50d407
--- /dev/null
+++ b/src/components/Stats/alertBadge.spec.js
@@ -0,0 +1,274 @@
+// @vitest-environment happy-dom
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createAlertBadge } from './alertBadge'
+
+const page = { loaded: 0, apex: 0 }
+vi.mock('./StatsPage.vue', () => {
+ page.loaded++
+ return { default: {} }
+})
+vi.mock('vue3-apexcharts', () => {
+ page.apex++
+ return { default: {} }
+})
+
+const items = (...alerts) => ({ items: alerts.map((n, i) => ({ id: `i${i}`, alerts: n })) })
+const gone = () => Object.assign(new Error('gone'), { response: { status: 404 } })
+
+function make(over = {}) {
+ const answers = { app_management: items(1, 2), virt_management: items(4), core: items(0) }
+ const list = vi.fn(source => (answers[source] instanceof Error ? Promise.reject(answers[source]) : Promise.resolve(answers[source])))
+ const state = { hidden: false }
+ const badge = createAlertBadge({ list, hasVm: () => Promise.resolve(true), delayMs: 2000, isHidden: () => state.hidden, ...over })
+ return { badge, list, answers, state }
+}
+
+const sourcesAsked = list => list.mock.calls.map(c => c[0]).sort()
+
+beforeEach(() => vi.useFakeTimers())
+afterEach(() => vi.useRealTimers())
+
+describe('alertBadge.load', () => {
+ it('asks app_management and core, and virt only when the module is present', async () => {
+ const there = make()
+ await there.badge.load()
+ expect(sourcesAsked(there.list)).toEqual(['app_management', 'core', 'virt_management'])
+
+ const absent = make({ hasVm: () => Promise.resolve(false) })
+ await absent.badge.load()
+ expect(sourcesAsked(absent.list)).toEqual(['app_management', 'core'])
+ expect(absent.badge.counts.virt_management).toBe(0)
+ })
+
+ it('counts the alerts of the items and ignores junk', async () => {
+ const { badge, answers } = make()
+ answers.app_management = items(-1, Number.NaN, '3', 2, Infinity, null, undefined, 1.5)
+ answers.virt_management = { items: [{ alerts: 4 }, 'junk', null, { id: 'x' }] }
+ answers.core = { items: Array.from({ length: 600 }, () => ({ alerts: 1 })) }
+
+ await badge.load()
+
+ expect(badge.counts).toEqual({ app_management: 3.5, virt_management: 4, core: 500 })
+ expect(badge.total()).toBe(507.5)
+ })
+
+ it('caps the sum at 999', async () => {
+ const { badge, answers } = make()
+ answers.app_management = items(900, 900)
+ await badge.load()
+ expect(badge.counts.app_management).toBe(999)
+ })
+
+ it.each([
+ ['a failing source', new Error('boom')],
+ ['a 404', gone()],
+ ['an answer that is not a list', null],
+ ['an answer with a hostile items', { items: 'x' }],
+ ])('%s counts 0 and does not throw', async (_, answer) => {
+ const { badge, answers } = make()
+ answers.app_management = answer
+ answers.virt_management = items(2)
+ await expect(badge.load()).resolves.toBeUndefined()
+ expect(badge.counts).toEqual({ app_management: 0, virt_management: 2, core: 0 })
+ })
+
+ it('a failing module probe counts virt as 0', async () => {
+ const { badge, list } = make({ hasVm: () => Promise.reject(new Error('x')) })
+ await badge.load()
+ expect(sourcesAsked(list)).toEqual(['app_management', 'core'])
+ })
+
+ it('a source that fails later with a non-404 error keeps its previous count', async () => {
+ const { badge, answers } = make()
+ await badge.load()
+ expect(badge.counts.app_management).toBe(3)
+ answers.app_management = new Error('down')
+ answers.virt_management = Object.assign(new Error('busy'), { response: { status: 503 } })
+ await badge.load()
+ expect(badge.counts.app_management).toBe(3)
+ expect(badge.counts.virt_management).toBe(4)
+ })
+
+ it('a source that answers 404 later goes back to 0', async () => {
+ const { badge, answers } = make()
+ await badge.load()
+ answers.app_management = gone()
+ await badge.load()
+ expect(badge.counts.app_management).toBe(0)
+ })
+})
+
+describe('alertBadge.refreshSoon', () => {
+ it('debounces: ten calls in 100 ms make one load after delayMs', async () => {
+ const { badge, list } = make()
+ for (let i = 0; i < 10; i++) {
+ badge.refreshSoon()
+ await vi.advanceTimersByTimeAsync(10)
+ }
+ expect(list).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(1)
+ await vi.advanceTimersByTimeAsync(10000)
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(1)
+ })
+
+ it('calls during a flight make exactly one more load after it', async () => {
+ const gate = { release: null }
+ const { badge, list } = make()
+ list.mockImplementation(source => source === 'core'
+ ? new Promise((resolve) => {
+ gate.release = () => resolve(items(0))
+ })
+ : Promise.resolve(items(1)))
+ const first = badge.load()
+ await vi.advanceTimersByTimeAsync(0)
+ badge.refreshSoon()
+ badge.refreshSoon()
+ await vi.advanceTimersByTimeAsync(2000)
+ badge.refreshSoon()
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(1)
+
+ list.mockImplementation(() => Promise.resolve(items(1)))
+ gate.release()
+ await first
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(2)
+ await vi.advanceTimersByTimeAsync(10000)
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(2)
+ })
+
+ it('asks nothing while hidden, and once when shown again', async () => {
+ const { badge, list, state } = make()
+ state.hidden = true
+ badge.refreshSoon()
+ badge.refreshSoon()
+ await vi.advanceTimersByTimeAsync(10000)
+ expect(list).not.toHaveBeenCalled()
+
+ state.hidden = false
+ document.dispatchEvent(new Event('visibilitychange'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(1)
+
+ document.dispatchEvent(new Event('visibilitychange'))
+ await vi.advanceTimersByTimeAsync(10000)
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(1)
+ })
+
+ it('a timer that fires while hidden asks nothing, and the show asks once', async () => {
+ const { badge, list, state } = make()
+ badge.refreshSoon()
+ state.hidden = true
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(list).not.toHaveBeenCalled()
+ state.hidden = false
+ document.dispatchEvent(new Event('visibilitychange'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(list.mock.calls.filter(c => c[0] === 'core')).toHaveLength(1)
+ })
+})
+
+describe('alertBadge after the tab was hidden', () => {
+ const coreCalls = list => list.mock.calls.filter(c => c[0] === 'core').length
+ const show = async (state, awayMs) => {
+ state.hidden = true
+ document.dispatchEvent(new Event('visibilitychange'))
+ await vi.advanceTimersByTimeAsync(awayMs)
+ state.hidden = false
+ document.dispatchEvent(new Event('visibilitychange'))
+ await vi.advanceTimersByTimeAsync(0)
+ }
+
+ it('is read again when the tab shows after more than 60 s hidden', async () => {
+ const { badge, list, state } = make()
+ await badge.load()
+ expect(coreCalls(list)).toBe(1)
+ await show(state, 61000)
+ expect(coreCalls(list)).toBe(2)
+ badge.stop()
+ })
+
+ it('is not read again after a short absence', async () => {
+ const { badge, list, state } = make()
+ await badge.load()
+ await show(state, 30000)
+ expect(coreCalls(list)).toBe(1)
+ badge.stop()
+ })
+
+ it('is read once, not twice, when an event was waiting for the tab to show', async () => {
+ const { badge, list, state } = make()
+ await badge.load()
+ state.hidden = true
+ document.dispatchEvent(new Event('visibilitychange'))
+ badge.refreshSoon()
+ await vi.advanceTimersByTimeAsync(61000)
+ state.hidden = false
+ document.dispatchEvent(new Event('visibilitychange'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(coreCalls(list)).toBe(2)
+ badge.stop()
+ })
+
+ it('stops listening with stop()', async () => {
+ const { badge, list, state } = make()
+ await badge.load()
+ badge.stop()
+ await show(state, 61000)
+ expect(coreCalls(list)).toBe(1)
+ })
+})
+
+describe('alertBadge.stop', () => {
+ it('clears the timer and the listener', async () => {
+ const { badge, list, state } = make()
+ badge.refreshSoon()
+ expect(vi.getTimerCount()).toBe(1)
+ badge.stop()
+ expect(vi.getTimerCount()).toBe(0)
+
+ state.hidden = true
+ badge.refreshSoon()
+ badge.stop()
+ state.hidden = false
+ document.dispatchEvent(new Event('visibilitychange'))
+ await vi.advanceTimersByTimeAsync(10000)
+ expect(list).not.toHaveBeenCalled()
+ })
+
+ it('ignores an answer that arrives late', async () => {
+ const { badge, list } = make()
+ const releases = []
+ list.mockImplementation(() => new Promise((resolve) => {
+ releases.push(() => resolve(items(5)))
+ }))
+ const load = badge.load()
+ await vi.advanceTimersByTimeAsync(0)
+ badge.stop()
+ releases.forEach(release => release())
+ await load
+ expect(badge.total()).toBe(0)
+ })
+})
+
+describe('importing alertBadge.js', () => {
+ it('does not load the Statistics page or apexcharts', async () => {
+ vi.resetModules()
+ page.loaded = 0
+ page.apex = 0
+ const mod = await import('./alertBadge')
+ expect(typeof mod.alertBadge.load).toBe('function')
+ expect(page).toEqual({ loaded: 0, apex: 0 })
+ })
+
+ it('imports only vue and the module probe, and loads the statistics client on the first ask', () => {
+ const source = readFileSync(join(__dirname, 'alertBadge.js'), 'utf8')
+ const imports = [...source.matchAll(/^import .* from '(.+)'$/gm)].map(m => m[1])
+ expect(imports.sort()).toEqual(['@/components/VirtualMachines/entry', 'vue'])
+ expect([...source.matchAll(/import\('(.+?)'\)/g)].map(m => m[1])).toEqual(['@/service/stats'])
+ })
+})
diff --git a/src/components/Stats/contract.integration.spec.js b/src/components/Stats/contract.integration.spec.js
new file mode 100644
index 00000000..32843c9b
--- /dev/null
+++ b/src/components/Stats/contract.integration.spec.js
@@ -0,0 +1,221 @@
+// @vitest-environment happy-dom
+// The page, the real client (src/service/stats.js) and the real axios instance, whose adapter is the stub of the contract.
+import Buefy from 'buefy'
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { handle } from '../../../mock/stats-stub/handlers.js'
+import { createAlertBadge } from './alertBadge'
+import StatsPage from './StatsPage.vue'
+import { hasVirtualMachines } from '@/components/VirtualMachines/entry'
+import i18n from '@/plugins/i18n'
+import { instance } from '@/service/service'
+import { statsApi } from '@/service/stats'
+
+vi.mock('@/assets/lang', async () => ({ default: { en_us: (await import('@/assets/lang/en_US.json')).default } }))
+// the session store and the router are not under test; the real client and axios instance are
+vi.mock('@/store', () => ({ default: { commit: vi.fn(), state: {} } }))
+vi.mock('@/router', () => ({ default: { replace: vi.fn() } }))
+vi.mock('@/components/VirtualMachines/entry', () => ({ hasVirtualMachines: vi.fn() }))
+vi.mock('vue3-apexcharts', () => ({ __esModule: true, default: { name: 'ApexStub', props: ['options', 'series', 'type', 'height'], render: () => null } }))
+
+const NOW = 1760000000
+const original = instance.defaults.adapter
+let stub
+let asked
+let tamper
+
+// The adapter: what axios would send over the network goes to the stub instead.
+function adapter(config) {
+ const url = new URL(config.url, 'http://stub.test')
+ const body = typeof config.data === 'string' && config.data ? JSON.parse(config.data) : null
+ asked.push(`${(config.method || 'get').toUpperCase()} ${url.pathname}`)
+ const { status, body: data } = handle({ method: config.method || 'get', path: url.pathname, query: config.params || {}, body: tamper ? tamper(body) : body }, stub)
+ const response = { data, status, statusText: String(status), headers: {}, config, request: {} }
+ if (status >= 200 && status < 300) {
+ return Promise.resolve(response)
+ }
+ return Promise.reject(Object.assign(new Error(`Request failed with status code ${status}`), { isAxiosError: true, config, response }))
+}
+
+let wrapper
+async function mountPage() {
+ wrapper = mount(StatsPage, { attachTo: document.body, global: { plugins: [Buefy, i18n] } })
+ await flushPromises()
+ await flushPromises()
+ return wrapper
+}
+
+const names = () => [...document.querySelectorAll('.stats-list .stats-item-name')].map(el => el.textContent)
+const apexes = () => wrapper.findAllComponents({ name: 'ApexStub' })
+const seriesNames = () => apexes().flatMap(a => a.props('series').map(s => s.name))
+function choose(name) {
+ [...document.querySelectorAll('.stats-list .stats-item')].find(b => b.querySelector('.stats-item-name').textContent === name).click()
+}
+const button = (selector, label) => [...document.querySelectorAll(selector)].find(b => b.textContent.trim() === label)
+const askedFor = prefix => asked.filter(line => line.includes(prefix))
+
+beforeEach(() => {
+ document.body.innerHTML = ''
+ vi.clearAllMocks()
+ stub = { envelope: 'real', now: NOW, alerts: false, down: [], rules: new Map() }
+ asked = []
+ tamper = null
+ instance.defaults.adapter = adapter
+ hasVirtualMachines.mockResolvedValue(true)
+})
+afterEach(() => {
+ wrapper?.unmount()
+ wrapper = null
+ instance.defaults.adapter = original
+})
+
+describe('the page against the stub of the contract', () => {
+ it('lists the containers, the virtual machines and the machine, in the real envelopes', async () => {
+ await mountPage()
+ expect(names().sort()).toEqual(['Debian', 'Immich / server', 'Jellyfin / web', 'Plex / web', 'Windows', 'postgres', 'stagevet-box'])
+ expect([...document.querySelectorAll('.stats-group-title')].map(el => el.textContent)).toEqual(['Containers', 'Other containers', 'Virtual Machines', 'Machine'])
+ expect(document.querySelector('.stats-column-error')).toBeNull()
+ })
+
+ it('with the core bare, shows the core column as failed with Retry, and the two others work', async () => {
+ stub.envelope = 'bare'
+ await mountPage()
+ expect(document.querySelector('.stats-column-error').textContent).toContain('Machine could not be read.')
+ expect(button('.stats-column-error button', 'Retry')).toBeDefined()
+ expect(names()).toContain('Jellyfin / web')
+ expect(names()).toContain('Debian')
+ expect(names()).not.toContain('stagevet-box')
+ })
+
+ it('with one source answering 503, shows that column failing and the others working, then recovers on Retry', async () => {
+ stub.down = ['virt_management']
+ await mountPage()
+ expect(document.querySelector('.stats-column-error').textContent).toContain('Virtual Machines did not answer.')
+ expect(names()).toContain('Jellyfin / web')
+ expect(names()).toContain('stagevet-box')
+ expect(names()).not.toContain('Debian')
+
+ stub.down = []
+ button('.stats-column-error button', 'Retry').click()
+ await flushPromises()
+ expect(names()).toContain('Debian')
+ expect(document.querySelector('.stats-column-error')).toBeNull()
+ })
+
+ it('answers 503 stats_unavailable for the item named broken, through the real client', async () => {
+ await expect(statsApi.series('app_management', 'broken', 'live')).rejects.toMatchObject({ response: { status: 503, data: { message: 'stats_unavailable' } } })
+ })
+})
+
+describe('the badge of the Statistics icon against the stub', () => {
+ it('counts the item that has 2 alerts, and asks nothing of the virt module when it is absent', async () => {
+ stub.alerts = true
+ const badge = createAlertBadge({ hasVm: () => Promise.resolve(false) })
+ await badge.load()
+ expect(badge.total()).toBe(2)
+ expect(askedFor('/v2/virt_management')).toEqual([])
+ expect(askedFor('/v2/app_management/stats')).toHaveLength(1)
+ expect(askedFor('/v1/sys/stats')).toHaveLength(1)
+ badge.stop()
+ })
+
+ it('asks the virt module when it is there', async () => {
+ stub.alerts = true
+ const badge = createAlertBadge({ hasVm: () => Promise.resolve(true) })
+ await badge.load()
+ expect(askedFor('/v2/virt_management/stats')).toHaveLength(1)
+ expect(badge.total()).toBe(2)
+ badge.stop()
+ })
+})
+
+describe('the charts against the stub', () => {
+ it('draws the memory of a VM without the used series and says why', async () => {
+ await mountPage()
+ choose('Debian')
+ await flushPromises()
+ expect(seriesNames()).toContain('Used')
+
+ choose('Windows')
+ await flushPromises()
+ expect(seriesNames()).toContain('Assigned')
+ expect(seriesNames()).not.toContain('Used')
+ expect(document.body.textContent).toContain('The memory used inside the virtual machine is not reported.')
+ })
+
+ it('hands the 7-day gap to apexcharts as a null', async () => {
+ await mountPage()
+ expect(apexes().flatMap(a => a.props('series')).some(s => s.data.some(p => p.y === null))).toBe(false)
+
+ button('.stats-ranges button', '7 days').click()
+ await flushPromises()
+ const withGap = apexes().flatMap(a => a.props('series')).filter(s => s.data.some(p => p.y === null))
+ expect(withGap.length).toBeGreaterThan(0)
+ expect(withGap[0].data.some(p => typeof p.y === 'number')).toBe(true)
+ })
+
+ it('after twenty quick changes of item, shows one set of curves, those of the last item', async () => {
+ await mountPage()
+ for (let i = 0; i < 20; i++) {
+ choose(i % 2 === 0 ? 'Plex / web' : 'Jellyfin / web')
+ }
+ await flushPromises()
+ await flushPromises()
+ expect(document.querySelector('.stats-name').textContent).toBe('Jellyfin / web')
+
+ const expected = handle({ method: 'GET', path: '/v2/app_management/stats/app%3Ajellyfin%3Aweb%3A1', query: { range: 'live' } }, stub).body
+ const user = expected.series.find(s => s.chart === 'cpu' && s.name === 'user').points.map(p => p[1])
+ const shown = apexes().flatMap(a => a.props('series')).filter(s => s.name === 'User')
+ expect(shown).toHaveLength(1)
+ expect(shown[0].data.map(p => p.y)).toEqual(user)
+ })
+})
+
+describe('the alert settings against the stub', () => {
+ async function openDialog() {
+ button('.stats-head button', 'Alert settings').click()
+ await flushPromises()
+ await flushPromises()
+ }
+ async function typeThreshold(value) {
+ const field = document.querySelector('.stats-rule [data-field="threshold"]')
+ field.value = value
+ field.dispatchEvent(new Event('input'))
+ await flushPromises()
+ return field
+ }
+
+ it('the stub refuses a threshold of 0 with 400 bad_rules, through the real client', async () => {
+ await expect(statsApi.setAlerts('app_management', 'app:jellyfin:web:1', [{ key: 'cpu_high', enabled: true, threshold: 0, minutes: 10 }]))
+ .rejects.toMatchObject({ response: { status: 400, data: { message: 'bad_rules: rules[0].threshold' } } })
+ })
+
+ it('shows the fixed refusal sentence and marks the threshold when the service refuses a value', async () => {
+ await mountPage()
+ await openDialog()
+ expect(document.querySelector('.stats-rule [data-field="threshold"]').getAttribute('aria-invalid')).toBeNull()
+
+ // the page lets 50 through; the service is made to receive 0
+ tamper = body => ({ rules: body.rules.map((rule, i) => (i === 0 ? { ...rule, threshold: 0 } : rule)) })
+ await typeThreshold('50')
+ button('.stats-dialog-actions button', 'Save').click()
+ await flushPromises()
+ await flushPromises()
+
+ expect(document.querySelector('.stats-dialog-error').textContent).toBe('These values were refused.')
+ expect(document.querySelector('.stats-rule [data-field="threshold"]').getAttribute('aria-invalid')).toBe('true')
+ expect(document.querySelector('.stats-dialog').textContent).not.toContain('bad_rules')
+ })
+
+ it('keeps what a valid save sent', async () => {
+ await mountPage()
+ await openDialog()
+ await typeThreshold('75')
+ button('.stats-dialog-actions button', 'Save').click()
+ await flushPromises()
+ await flushPromises()
+ expect(document.querySelector('.stats-dialog')).toBeNull()
+ // the first item of the list is Immich / server
+ expect(stub.rules.get('app:immich:server:1')[0]).toMatchObject({ key: 'cpu_high', threshold: 75 })
+ })
+})
diff --git a/src/components/Stats/entry.js b/src/components/Stats/entry.js
new file mode 100644
index 00000000..2204f2c9
--- /dev/null
+++ b/src/components/Stats/entry.js
@@ -0,0 +1,40 @@
+// 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 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')
+ 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..8fc65b7e
--- /dev/null
+++ b/src/components/Stats/entry.spec.js
@@ -0,0 +1,90 @@
+// @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)
+ })
+})
+
+describe('the Statistics window when it is already open', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ document.body.innerHTML = '
Statistics
'
+ })
+
+ 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')
+ })
+})
diff --git a/src/components/Stats/htmlSinks.spec.js b/src/components/Stats/htmlSinks.spec.js
new file mode 100644
index 00000000..bb413076
--- /dev/null
+++ b/src/components/Stats/htmlSinks.spec.js
@@ -0,0 +1,244 @@
+import { readFileSync, readdirSync } from 'node:fs'
+import { join } from 'node:path'
+import { babelParse, parse } from '@vue/compiler-sfc'
+import { describe, expect, it } from 'vitest'
+
+// Buefy shows the message of a toast, a snackbar, a notification and a dialog,
+// and the content of a modal, as HTML. The title of a VM, an error from the
+// service or a file name must not reach one as it is. This reads the sources
+// of the folder, so that any new call has to be written in a way that is safe:
+// the message is the text of ours, or escaped as a whole.
+
+// what Buefy shows as HTML, by the call that gives it
+const SINKS = {
+ 'toast.open': 'message',
+ 'snackbar.open': 'message',
+ 'notification.open': 'message',
+ 'dialog.alert': 'message',
+ 'dialog.confirm': 'message',
+ 'dialog.prompt': 'message',
+ 'modal.open': 'content',
+}
+// opens a modal with the options it is given, in the place of the argument
+const MODAL_OPENERS = { openWithEscape: 1 }
+// and the components that take the same as a prop
+const COMPONENTS = ['bnotification', 'bmodal', 'bdialog', 'bsnackbar']
+const PROPS = ['message', 'content']
+// the file that hands on the options its callers wrote, which are checked as
+// calls of modal.open
+const HANDS_ON = ['modalStack.js']
+
+function walk(node, visit) {
+ if (Array.isArray(node)) {
+ node.forEach(child => walk(child, visit))
+ } else if (node && typeof node.type === 'string') {
+ visit(node)
+ Object.entries(node).forEach(([key, child]) => key !== 'loc' && walk(child, visit))
+ }
+}
+
+// What a message may be: a translation of a fixed key, with no parameters to
+// put in, or a call of escape on the whole of it. Escaping a part, or adding
+// something to what is escaped, is not enough.
+function isSafe(node, escape) {
+ if (node.type === 'ConditionalExpression') {
+ return isSafe(node.consequent, escape) && isSafe(node.alternate, escape)
+ }
+ // a VNode array (what a notification takes): h(...) calls only, so that no service text becomes HTML
+ if (node.type === 'ArrayExpression') {
+ return node.elements.every(el => el?.type === 'CallExpression' && el.callee.type === 'Identifier' && el.callee.name === 'h')
+ }
+ if (node.type !== 'CallExpression') {
+ return false
+ }
+ const { callee } = node
+ if (callee.type === 'Identifier' && callee.name === escape) {
+ return true
+ }
+ const name = callee.type === 'MemberExpression' && !callee.computed ? callee.property.name : callee.name
+ return name === '$t' && node.arguments.length === 1 && node.arguments[0].type === 'StringLiteral'
+}
+
+function scanScript(code, file, found) {
+ const { program } = babelParse(code, { sourceType: 'module' })
+ // the local name of lodash's escape: any other escape is not that
+ const escape = program.body.find(n => n.type === 'ImportDeclaration' && n.source.value === 'lodash/escape')?.specifiers[0]?.local.name
+
+ walk(program, (node) => {
+ if (node.type !== 'CallExpression' && node.type !== 'OptionalCallExpression') {
+ return
+ }
+ const { callee } = node
+ let kind
+ let argument
+ let property
+ if (callee.type === 'Identifier' && Object.hasOwn(MODAL_OPENERS, callee.name)) {
+ kind = callee.name
+ argument = node.arguments[MODAL_OPENERS[kind]]
+ property = 'content'
+ } else if (callee.type === 'MemberExpression' || callee.type === 'OptionalMemberExpression') {
+ kind = `${callee.object.property?.name ?? callee.object.name}.${callee.property.name}`
+ argument = node.arguments[0]
+ property = Object.hasOwn(SINKS, kind) ? SINKS[kind] : undefined
+ }
+ if (!property) {
+ return
+ }
+
+ const at = `${file}: ${kind}`
+ found.sinks.push(at)
+ if (argument?.type !== 'ObjectExpression') {
+ found.problems.push(`${at}: the options are not written out where it is called`)
+ return
+ }
+ for (const prop of argument.properties) {
+ if (prop.type === 'SpreadElement' && HANDS_ON.includes(file)) {
+ continue
+ }
+ if (prop.type !== 'ObjectProperty' || prop.computed) {
+ found.problems.push(`${at}: an option that is a spread, a method or a computed name`)
+ } else if ((prop.key.name ?? prop.key.value) === property && !isSafe(prop.value, escape)) {
+ found.problems.push(`${at}: ${code.slice(prop.start, prop.end)}`)
+ }
+ }
+ })
+}
+
+function scanTemplate(descriptor, file, found) {
+ const visit = (node) => {
+ if (node.type === 1 && COMPONENTS.includes(node.tag.toLowerCase().replaceAll('-', ''))) {
+ found.sinks.push(`${file}: <${node.tag}>`)
+ for (const prop of node.props) {
+ // a text written out is safe; one that comes from the data is not
+ if (prop.type === 7 && prop.name === 'bind' && (!prop.arg || PROPS.includes(prop.arg.content))) {
+ found.problems.push(`${file}: <${node.tag}> takes its ${prop.arg?.content ?? 'props'} from the data`)
+ }
+ }
+ }
+ node.children?.forEach(visit)
+ }
+ visit(descriptor.template.ast)
+
+ // a call in the template is not read here: it goes in a method, where it is
+ if (/\b(?:toast|snackbar|notification|modal)\.open\(|\bdialog\.(?:alert|confirm|prompt)\(/.test(descriptor.template.content)) {
+ found.problems.push(`${file}: a call that opens a message or a modal is written in the template`)
+ }
+}
+
+// every message and modal that source opens, and what is wrong with them
+function scan(file, source) {
+ const found = { sinks: [], problems: [] }
+ if (file.endsWith('.vue')) {
+ const { descriptor } = parse(source, { filename: file })
+ for (const script of [descriptor.script, descriptor.scriptSetup]) {
+ if (script) {
+ scanScript(script.content, file, found)
+ }
+ }
+ if (descriptor.template) {
+ scanTemplate(descriptor, file, found)
+ }
+ } else {
+ scanScript(source, file, found)
+ }
+ return found
+}
+
+describe('the messages Buefy shows as HTML', () => {
+ const folder = import.meta.dirname
+ const files = readdirSync(folder).filter(f => /\.(?:vue|js)$/.test(f) && !f.endsWith('.spec.js'))
+ const sources = Object.fromEntries(files.map(f => [f, readFileSync(join(folder, f), 'utf8')]))
+ sources['stats.js'] = readFileSync(join(folder, '../../service/stats.js'), 'utf8')
+
+ it('are escaped as a whole, or a fixed text', () => {
+ const sinks = []
+ const problems = []
+ for (const [file, source] of Object.entries(sources)) {
+ const found = scan(file, source)
+ sinks.push(...found.sinks)
+ problems.push(...found.problems)
+ }
+ expect(problems).toEqual([])
+ // the window opened by the entry
+ expect(sinks).toContain('entry.js: openWithEscape')
+ })
+
+ it('are never written with v-html or innerHTML in the Statistics files', () => {
+ for (const [file, source] of Object.entries(sources)) {
+ expect(source, file).not.toMatch(/v-html|v-dompurify-html|innerHTML/)
+ }
+ })
+})
+
+describe('the guard of the messages', () => {
+ const imports = 'import escape from \'lodash/escape\'\n'
+ const call = (body, file = 'probe.js') => scan(file, imports + body).problems
+ const vue = (template, script = '') => `
${template}
\n`
+
+ const unsafe = {
+ 'a plain error in a toast': 'this.$buefy.toast.open({ message: e.message })',
+ 'a bare string': 'this.$buefy.toast.open(\'hello\')',
+ 'a bare template in a snackbar': 'this.$buefy.snackbar.open(`hello`)',
+ 'options held in a variable': 'this.$buefy.toast.open(options)',
+ 'the message in a variable': 'this.$buefy.toast.open({ message: title })',
+ 'the message as a shorthand': 'this.$buefy.toast.open({ message })',
+ 'the brace on the next line': 'this.$buefy.toast.open(\n\t{\n\t\tmessage: title,\n\t},\n)',
+ 'a message with a part escaped': 'this.$buefy.toast.open({ message: escape(title) + e.message })',
+ 'a message with a part before the escape': `this.$buefy.toast.open({ message: 'Error: ' + escape(title) })`,
+ 'a translation with parameters': 'this.$buefy.toast.open({ message: this.$t(\'{name} is gone\', { name: title }) })',
+ 'a translation of a key that is not fixed': 'this.$buefy.toast.open({ message: this.$t(key) })',
+ 'a ternary with one side not safe': 'this.$buefy.toast.open({ message: ok ? this.$t(\'Done\') : e.message })',
+ 'a notification': 'this.$buefy.notification.open({ message: title })',
+ 'a dialog': 'this.$buefy.dialog.confirm({ message: title })',
+ 'an alert with a bare string': 'this.$buefy.dialog.alert(title)',
+ 'a modal with a content': 'this.$buefy.modal.open({ content: title })',
+ 'a modal opened by the helper with a content': 'openWithEscape(this.$buefy, { content: title })',
+ 'a modal opened by the helper with options held': 'openWithEscape(this.$buefy, options)',
+ 'a spread': 'this.$buefy.toast.open({ ...options })',
+ 'a computed name': 'this.$buefy.toast.open({ [name]: title })',
+ 'a call that does not say it is escape': 'this.$buefy.toast.open({ message: other(title) })',
+ 'a notification with a string and a tag': 'this.$buefy.notification.open({ message: \'\' + name + \'\' })',
+ 'a notification with an array that holds a string': 'this.$buefy.notification.open({ message: [h("p", name), name] })',
+ }
+
+ it.each(Object.entries(unsafe))('catches %s', (_, body) => {
+ expect(call(body)).not.toEqual([])
+ })
+
+ it('catches an escape that is not lodash\'s', () => {
+ expect(scan('probe.js', 'this.$buefy.toast.open({ message: escape(title) })').problems).not.toEqual([])
+ })
+
+ it('catches them in the script of a component, and in a template', () => {
+ expect(scan('probe.vue', vue('', 'this.$buefy.toast.open({ message: title })')).problems).not.toEqual([])
+ expect(scan('probe.vue', vue('')).problems).not.toEqual([])
+ expect(scan('probe.vue', vue('')).problems).not.toEqual([])
+ expect(scan('probe.vue', vue('')).problems).not.toEqual([])
+ expect(scan('probe.vue', vue('')).problems).not.toEqual([])
+ })
+
+ const safe = {
+ 'an escaped error': 'this.$buefy.toast.open({ message: escape(e.response?.data?.message || e.message), type: \'is-danger\' })',
+ 'a fixed text': 'this.$buefy.toast.open({ message: this.$t(\'Copied to clipboard\'), type: \'is-success\' })',
+ 'an escaped translation with parameters': 'this.$buefy.toast.open({ message: escape(this.$t(\'{name} is gone\', { name: title })) })',
+ 'one of two fixed texts': 'this.$buefy.dialog.confirm({ message: off ? this.$t(\'Off\') : this.$t(\'Reset\') })',
+ 'the brace on the next line, and the message safe': 'this.$buefy.toast.open(\n\t{\n\t\tmessage: escape(title),\n\t},\n)',
+ 'a modal with a component': 'this.$buefy.modal.open({ component: Form, props: { host }, canCancel: [] })',
+ 'a modal opened by the helper': 'openWithEscape(this.$buefy, { component: Form, props: { host } })',
+ 'a notification with a VNode array': 'this.$buefy.notification.open({ message: [h("p", text), h("button", { onClick: go }, label)] })',
+ 'a dialog with no message': 'this.$buefy.dialog.confirm({ title: title, onConfirm: go })',
+ }
+
+ it.each(Object.entries(safe))('lets through %s', (_, body) => {
+ expect(call(body)).toEqual([])
+ })
+
+ it('lets through a text written out in a component, and one that is not a sink', () => {
+ expect(scan('probe.vue', vue('')).problems).toEqual([])
+ })
+
+ it('counts what it looked at, so that a guard that finds nothing says so', () => {
+ const { sinks } = scan('probe.vue', vue('', 'this.$buefy.toast.open({ message: escape(x) })'))
+ expect(sinks).toEqual(['probe.vue: toast.open', 'probe.vue: '])
+ })
+})
diff --git a/src/components/Stats/poller.js b/src/components/Stats/poller.js
new file mode 100644
index 00000000..b60d192d
--- /dev/null
+++ b/src/components/Stats/poller.js
@@ -0,0 +1,80 @@
+// 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
+ const wanted = again
+ again = false
+ if (wanted && !isHidden()) {
+ 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..200b879d
--- /dev/null
+++ b/src/components/Stats/poller.spec.js
@@ -0,0 +1,143 @@
+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() 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 })
+ 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)
+ })
+})
diff --git a/src/components/Stats/resourceAlerts.js b/src/components/Stats/resourceAlerts.js
new file mode 100644
index 00000000..824d87f3
--- /dev/null
+++ b/src/components/Stats/resourceAlerts.js
@@ -0,0 +1,115 @@
+// What a bus event `resource:alert-firing` / `resource:alert-resolved` becomes on the dashboard: a bounded, deduplicated,
+// text-only notification. Pure: the caller draws it. Bus properties are strings, and every one of them is untrusted.
+import { formatDateTime } from './statsFormat'
+import { clean } from '@/components/settings/osPlan'
+
+const KINDS = ['app-container', 'container', 'vm', 'host']
+const MAX_VALUE = 10000
+const MAX_FIRING_KEPT = 200
+
+// situation -> [firing key by what is known (value+date, value, date, neither), resolved key]
+export const SENTENCES = {
+ cpu_high: {
+ both: 'Processor of {name} at {value} % since {since}',
+ value: 'Processor of {name} at {value} %',
+ since: 'Processor of {name} high since {since}',
+ none: 'Processor of {name} is high',
+ resolved: 'Processor of {name} is back to normal',
+ },
+ mem_high: {
+ both: 'Memory of {name} at {value} % since {since}',
+ value: 'Memory of {name} at {value} %',
+ since: 'Memory of {name} high since {since}',
+ none: 'Memory of {name} is high',
+ resolved: 'Memory of {name} is back to normal',
+ },
+ vm_crashed: {
+ both: 'The virtual machine {name} crashed',
+ value: 'The virtual machine {name} crashed',
+ since: 'The virtual machine {name} crashed',
+ none: 'The virtual machine {name} crashed',
+ resolved: 'The virtual machine {name} is running again',
+ },
+}
+
+const has = (table, key) => typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key)
+
+// A string or number that is a finite percent, rounded; anything else (including '' and ' ', which Number() turns into 0) is absent.
+function percent(raw) {
+ if ((typeof raw !== 'string' || raw.trim() === '') && typeof raw !== 'number') {
+ return null
+ }
+ const n = Number(raw)
+ return Number.isFinite(n) && n >= 0 && n <= MAX_VALUE ? Math.round(n) : null
+}
+
+function date(raw) {
+ return typeof raw === 'string' && raw !== '' && !Number.isNaN(Date.parse(raw)) ? raw : null
+}
+
+export function createAlertFeed({ now = Date.now, max = 5, windowMs = 60000 } = {}) {
+ // `item|situation|state` -> when it was last accepted
+ let recent = new Map()
+ // `item|situation` -> true, for the firings accepted and not yet resolved
+ const firings = new Set()
+ let counter = 0
+
+ return {
+ accept(state, properties) {
+ if ((state !== 'firing' && state !== 'resolved') || properties === null || typeof properties !== 'object') {
+ return null
+ }
+ const { item, situation, kind } = properties
+ if (!has(SENTENCES, situation) || typeof item !== 'string' || item === '' || item.length > 300 || !KINDS.includes(kind)) {
+ return null
+ }
+ const key = `${item}|${situation}`
+ if (state === 'resolved' && !firings.has(key)) {
+ return null
+ }
+
+ const t = now()
+ recent = new Map([...recent].filter(([, at]) => t - at < windowMs))
+ const dedupe = `${key}|${state}`
+ if (recent.has(dedupe) || recent.size >= max) {
+ return null
+ }
+ recent.set(dedupe, t)
+
+ if (state === 'resolved') {
+ firings.delete(key)
+ } else {
+ firings.add(key)
+ if (firings.size > MAX_FIRING_KEPT) {
+ firings.delete(firings.values().next().value)
+ }
+ }
+
+ const sentences = SENTENCES[situation]
+ const params = { name: clean(properties.name, 80) || clean(item, 80) }
+ let textKey = sentences.resolved
+ if (state === 'firing') {
+ const value = percent(properties.value)
+ const since = date(properties.since)
+ if (value !== null) {
+ params.value = value
+ }
+ if (since !== null) {
+ params.since = since
+ }
+ textKey = sentences[value !== null ? (since !== null ? 'both' : 'value') : (since !== null ? 'since' : 'none')]
+ }
+ counter++
+ return { id: `${dedupe}#${counter}`, key, kind: state, item, text: { key: textKey, params }, open: { item } }
+ },
+ }
+}
+
+// The sentence of an accepted event, in the person's language; `since` is shown as a date.
+export function alertMessage(text, t, locale) {
+ const params = { ...text.params }
+ if (params.since !== undefined) {
+ params.since = formatDateTime(params.since, locale)
+ }
+ return t(text.key, params)
+}
diff --git a/src/components/Stats/resourceAlerts.spec.js b/src/components/Stats/resourceAlerts.spec.js
new file mode 100644
index 00000000..849971d6
--- /dev/null
+++ b/src/components/Stats/resourceAlerts.spec.js
@@ -0,0 +1,159 @@
+import { describe, expect, it } from 'vitest'
+import { HOSTILE } from './__fixtures__/contract'
+import { alertMessage, createAlertFeed } from './resourceAlerts'
+import { clean } from '@/components/settings/osPlan'
+
+const firing = (over = {}) => ({ item: 'app:immich', name: 'Immich / server', kind: 'app-container', situation: 'cpu_high', value: '93', minutes: '10', since: '2026-10-20T10:00:00Z', source: 'app_management', ...over })
+
+function feedAt() {
+ const clock = { t: 1000000 }
+ const feed = createAlertFeed({ now: () => clock.t })
+ return { feed, clock }
+}
+
+describe('createAlertFeed: what a firing event says', () => {
+ it.each([
+ ['value and date', {}, 'Processor of {name} at {value} % since {since}', { name: 'Immich / server', value: 93, since: '2026-10-20T10:00:00Z' }],
+ ['value only', { since: undefined }, 'Processor of {name} at {value} %', { name: 'Immich / server', value: 93 }],
+ ['value only, date garbage', { since: 'garbage' }, 'Processor of {name} at {value} %', { name: 'Immich / server', value: 93 }],
+ ['date only, value NaN', { value: 'NaN' }, 'Processor of {name} high since {since}', { name: 'Immich / server', since: '2026-10-20T10:00:00Z' }],
+ ['date only, value empty', { value: '' }, 'Processor of {name} high since {since}', { name: 'Immich / server', since: '2026-10-20T10:00:00Z' }],
+ ['neither', { value: 'Infinity', since: '' }, 'Processor of {name} is high', { name: 'Immich / server' }],
+ ['memory, both', { situation: 'mem_high', value: '93.4' }, 'Memory of {name} at {value} % since {since}', { name: 'Immich / server', value: 93, since: '2026-10-20T10:00:00Z' }],
+ ['memory, neither', { situation: 'mem_high', value: undefined, since: undefined }, 'Memory of {name} is high', { name: 'Immich / server' }],
+ ['a crash', { situation: 'vm_crashed', kind: 'vm', value: undefined }, 'The virtual machine {name} crashed', { name: 'Immich / server' }],
+ ])('%s', (_, over, key, params) => {
+ const got = createAlertFeed().accept('firing', firing(over))
+ expect(got).toMatchObject({ kind: 'firing', item: 'app:immich', text: { key, params }, open: { item: 'app:immich' } })
+ })
+
+ it.each([
+ ['-5'],
+ ['1e300'],
+ ['abc'],
+ [' '],
+ [null],
+ [{}],
+ ])('an odd value %j is left out of the sentence', (value) => {
+ const got = createAlertFeed().accept('firing', firing({ value }))
+ expect(got.text.key).toBe('Processor of {name} high since {since}')
+ expect(got.text.params).not.toHaveProperty('value')
+ })
+
+ it('a value is rounded to a whole percent and 0 is a value', () => {
+ expect(createAlertFeed().accept('firing', firing({ value: '0' })).text.params.value).toBe(0)
+ expect(createAlertFeed().accept('firing', firing({ value: '92.6' })).text.params.value).toBe(93)
+ })
+
+ it.each([['x'], [''], [undefined], ['__proto__'], ['cpu_low']])('an unknown situation %j gives null', (situation) => {
+ expect(createAlertFeed().accept('firing', firing({ situation }))).toBeNull()
+ })
+
+ it.each([['bogus'], [undefined]])('an unknown state %j gives null', (state) => {
+ expect(createAlertFeed().accept(state, firing())).toBeNull()
+ })
+
+ it.each([[undefined], [null], ['']])('properties %j give null and do not throw', (props) => {
+ expect(createAlertFeed().accept('firing', props)).toBeNull()
+ })
+
+ it('an item that is not a string, empty or too long gives null', () => {
+ const feed = createAlertFeed()
+ expect(feed.accept('firing', firing({ item: 42 }))).toBeNull()
+ expect(feed.accept('firing', firing({ item: '' }))).toBeNull()
+ expect(feed.accept('firing', firing({ item: 'x'.repeat(301) }))).toBeNull()
+ expect(feed.accept('firing', firing({ item: 'x'.repeat(300) }))).not.toBeNull()
+ })
+
+ it('an unknown kind gives null', () => {
+ expect(createAlertFeed().accept('firing', firing({ kind: 'toaster' }))).toBeNull()
+ expect(createAlertFeed().accept('firing', firing({ kind: undefined }))).toBeNull()
+ })
+
+ it('a hostile name is cleaned, cut and still text', () => {
+ const name = `${HOSTILE}\n\u0007${'y'.repeat(200)}`
+ const got = createAlertFeed().accept('firing', firing({ name }))
+ expect(typeof got.text.params.name).toBe('string')
+ expect(got.text.params.name).toBe(clean(name, 80))
+ expect(got.text.params.name.length).toBeLessThanOrEqual(80)
+ expect([...got.text.params.name].some(c => c.charCodeAt(0) < 32)).toBe(false)
+ })
+
+ it('a missing name falls back to the item', () => {
+ expect(createAlertFeed().accept('firing', firing({ name: undefined })).text.params.name).toBe('app:immich')
+ })
+})
+
+describe('createAlertFeed: dedupe, flood and resolved', () => {
+ it('the same item, situation and state within the window is accepted once, and again after it', () => {
+ const { feed, clock } = feedAt()
+ expect(feed.accept('firing', firing())).not.toBeNull()
+ clock.t += 59999
+ expect(feed.accept('firing', firing())).toBeNull()
+ clock.t += 2
+ expect(feed.accept('firing', firing())).not.toBeNull()
+ })
+
+ it('six distinct events in the window: the sixth is dropped, not queued', () => {
+ const { feed, clock } = feedAt()
+ const got = [1, 2, 3, 4, 5, 6].map(n => feed.accept('firing', firing({ item: `c${n}` })))
+ expect(got.slice(0, 5).every(Boolean)).toBe(true)
+ expect(got[5]).toBeNull()
+ clock.t += 60001
+ expect(feed.accept('firing', firing({ item: 'c7' }))).not.toBeNull()
+ // the dropped one was never remembered, so it is accepted when it comes again
+ expect(feed.accept('firing', firing({ item: 'c6' }))).not.toBeNull()
+ })
+
+ it('a resolved with no firing seen is null; with one it is accepted once, then null again', () => {
+ const { feed, clock } = feedAt()
+ expect(feed.accept('resolved', firing({ value: undefined }))).toBeNull()
+ feed.accept('firing', firing())
+ clock.t += 70000
+ const got = feed.accept('resolved', firing({ value: '12' }))
+ expect(got).toMatchObject({ kind: 'resolved', text: { key: 'Processor of {name} is back to normal' } })
+ expect(got.text.params).not.toHaveProperty('value')
+ clock.t += 70000
+ expect(feed.accept('resolved', firing())).toBeNull()
+ })
+
+ it('a firing the flood dropped does not let its resolved through', () => {
+ const { feed } = feedAt()
+ for (let n = 1; n <= 5; n++) {
+ feed.accept('firing', firing({ item: `c${n}` }))
+ }
+ expect(feed.accept('firing', firing({ item: 'late' }))).toBeNull()
+ expect(feed.accept('resolved', firing({ item: 'late' }))).toBeNull()
+ })
+
+ it('the three resolved sentences', () => {
+ const { feed, clock } = feedAt()
+ const keys = []
+ for (const situation of ['cpu_high', 'mem_high', 'vm_crashed']) {
+ feed.accept('firing', firing({ situation }))
+ clock.t += 70000
+ keys.push(feed.accept('resolved', firing({ situation })).text.key)
+ clock.t += 70000
+ }
+ expect(keys).toEqual(['Processor of {name} is back to normal', 'Memory of {name} is back to normal', 'The virtual machine {name} is running again'])
+ })
+
+ it('ids differ between notifications', () => {
+ const { feed } = feedAt()
+ expect(feed.accept('firing', firing({ item: 'a' })).id).not.toBe(feed.accept('firing', firing({ item: 'b' })).id)
+ })
+})
+
+describe('alertMessage', () => {
+ const t = (key, params) => key.replace(/\{(\w+)\}/g, (_, k) => params[k])
+
+ it('writes the sentence and dates with formatDateTime', () => {
+ const got = createAlertFeed().accept('firing', firing())
+ expect(alertMessage(got.text, t, 'en')).toMatch(/^Processor of Immich \/ server at 93 % since .*2026/)
+ })
+
+ it('a hostile name stays a string', () => {
+ const got = createAlertFeed().accept('firing', firing({ name: HOSTILE }))
+ expect(typeof alertMessage(got.text, t, 'en')).toBe('string')
+ })
+})
diff --git a/src/components/Stats/statsData.js b/src/components/Stats/statsData.js
new file mode 100644
index 00000000..c5a4ac2d
--- /dev/null
+++ b/src/components/Stats/statsData.js
@@ -0,0 +1,450 @@
+/**
+ * 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 as HTML, so the series NAME it receives is escaped first (`name`); `label` is for Vue text.
+ */
+import escape from 'lodash/escape'
+import { FORMAT_KEYS, formatNumber, 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'
+
+// 45 % towards white
+function lighten(hex) {
+ const channel = i => Math.round(Number.parseInt(hex.slice(1 + (i * 2), 3 + (i * 2)), 16) * 0.55 + (255 * 0.45))
+ return `#${[0, 1, 2].map(i => channel(i).toString(16).padStart(2, '0')).join('')}`
+}
+
+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: Array.from(clean(entry.detail, 800)).slice(0, 200).join('').trim() || 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, 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] },
+ { 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, locale }, response) {
+ // the series of this chart, in the order of the definition
+ const found = []
+ // each instance (an interface, a disk) has its own hue; the second line of the chart (sent, write) is the lighter one
+ const instances = []
+ def.keys.forEach((k, defIndex) => {
+ for (const [key, cleaned] of byKey) {
+ if (matches(k.key, key)) {
+ const instance = key.length > k.key.length ? key.slice(k.key.length + 1) : ''
+ if (instance && !instances.includes(instance)) {
+ instances.push(instance)
+ }
+ let color = k.color
+ if (instance && k.kind !== 'ref') {
+ color = PALETTE[instances.indexOf(instance) % PALETTE.length]
+ color = defIndex % 2 === 1 ? lighten(color) : color
+ }
+ found.push({ def: k, key, instance, color, points: cleaned.points })
+ }
+ }
+ })
+ 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 += ` · ${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 = []
+ // every line the person can toggle, hidden or not (the legend)
+ const legend = []
+ for (const f of list) {
+ const label = labelOf(f)
+ legend.push({ key: f.key, label, color: f.color, kind: f.def.kind, hidden: hidden.has(f.key) })
+ if (hidden.has(f.key)) {
+ continue
+ }
+ 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,
+ legend,
+ caption: chartCaption(def, response.scale, notes, t, locale),
+ 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, locale = 'en' } = {}) {
+ 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, locale }, 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) && !chart.noteCodes?.includes('no_cpu_limit')) {
+ 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: formatNumber(scale.cpu_allocation, locale) }))
+ }
+ } 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, { 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}`,
+ 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, datetimeUTC: false } },
+ 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
+ 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 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', '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' },
+ { 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(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
new file mode 100644
index 00000000..b3069374
--- /dev/null
+++ b/src/components/Stats/statsData.spec.js
@@ -0,0 +1,348 @@
+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'
+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(' {
+ 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('has no group, an id per chart, and formats with the formatters', () => {
+ const net = find(charts, 'network')
+ const cpu = find(charts, 'cpu')
+ const a = chartOptions(net, { locale: 'en', range: 'live', height: 200, scale })
+ const b = chartOptions(cpu, { locale: 'en', range: 'live', height: 200, scale })
+ expect(a.chart.group).toBeUndefined()
+ 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'), { 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'), { 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('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)
+ })
+})
+
+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: '-' })
+ })
+
+ 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', () => {
+ 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)
+ })
+})
+
+describe('the fixes of the final review', () => {
+ it('draws no allocation term in the scale when the processor has no limit', () => {
+ const cpu = { id: 'cpu', stacked: false, noteCodes: ['no_cpu_limit'], series: [{ key: 'cpu/total', kind: 'area', points: [{ x: 1, y: 10 }] }] }
+ expect(scaleMax(cpu, { cpu_allocation: 8 })).toBeCloseTo(11)
+ expect(scaleMax({ ...cpu, noteCodes: [] }, { cpu_allocation: 8 })).toBeCloseTo(880)
+ })
+
+ it('gives every interface its own colour, received and sent apart', () => {
+ const lines = ['eth0', 'eth1', 'eth2'].flatMap(n => [['network', `rx:${n}`, 'bytes_per_second', pts([1, 2])], ['network', `tx:${n}`, 'bytes_per_second', pts([1, 2])]])
+ const colors = find(buildCharts(resp(lines), { t }), 'network').series.map(s => s.color)
+ expect(colors).toHaveLength(6)
+ expect(new Set(colors).size).toBe(6)
+ })
+
+ it('writes the allocated cores with the locale', () => {
+ const fr = (key, p = {}) => key.replaceAll(/\{(\w+)\}/g, (_, k) => p[k])
+ expect(chartCaption({ id: 'cpu' }, { cpu_allocation: 0.5 }, [], fr, 'fr')).toBe('% of one core · 0,5 cores allocated')
+ expect(chartCaption({ id: 'cpu' }, { cpu_allocation: 0.5 }, [], fr, 'en')).toBe('% of one core · 0.5 cores allocated')
+ })
+
+ it('puts the time on local ticks', () => {
+ expect(chartOptions({ id: 'cpu', unit: 'percent', stacked: false, series: [] }, {}).xaxis.labels.datetimeUTC).toBe(false)
+ })
+})
diff --git a/src/components/Stats/statsFormat.js b/src/components/Stats/statsFormat.js
new file mode 100644
index 00000000..a94ddf02
--- /dev/null
+++ b/src/components/Stats/statsFormat.js
@@ -0,0 +1,126 @@
+// 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)
+
+// One formatter per locale and options: the page formats dozens of numbers at every poll.
+const formats = new Map()
+function numberFormat(locale, options) {
+ const id = `${locale}|${options.maximumFractionDigits}`
+ if (!formats.has(id)) {
+ try {
+ formats.set(id, new Intl.NumberFormat(locale, options))
+ } catch {
+ formats.set(id, new Intl.NumberFormat('en', options))
+ }
+ }
+ return formats.get(id)
+}
+
+// A plain number (cores, counts) with the decimal mark of the locale.
+export function formatNumber(value, locale) {
+ return numberFormat(locale, { maximumFractionDigits: 2 }).format(value)
+}
+
+// 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..efa6bb0e
--- /dev/null
+++ b/src/components/Stats/statsFormat.spec.js
@@ -0,0 +1,107 @@
+import { describe, expect, it, vi } from 'vitest'
+import { formatDateTime, formatDuration, formatNumber, 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('-')
+ })
+})
+
+describe('the number formats', () => {
+ it('builds one Intl.NumberFormat per locale and options', () => {
+ const Real = Intl.NumberFormat
+ const spy = vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (...args) { // eslint-disable-line prefer-arrow-callback -- a constructor
+ return new Real(...args)
+ })
+ try {
+ formatValue(1.5, 'count', 'de')
+ formatValue(2.5, 'count', 'de')
+ formatValue(3.5, 'count', 'de')
+ expect(spy.mock.calls.filter(c => c[0] === 'de')).toHaveLength(1)
+ } finally {
+ spy.mockRestore()
+ }
+ })
+
+ it('writes a number with the locale', () => {
+ expect(formatNumber(0.5, 'fr')).toBe('0,5')
+ expect(formatNumber(0.5, 'en')).toBe('0.5')
+ })
+})
diff --git a/src/components/Stats/statsKeys.spec.js b/src/components/Stats/statsKeys.spec.js
new file mode 100644
index 00000000..926d5e3d
--- /dev/null
+++ b/src/components/Stats/statsKeys.spec.js
@@ -0,0 +1,50 @@
+import { readFileSync, readdirSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { SENTENCES } from './resourceAlerts'
+import { STATS_KEYS } from './statsData'
+
+// A key used by the Statistics code that one of the language files lacks would show as the bare English sentence, or worse.
+const folder = import.meta.dirname
+const lang = file => join(folder, '../../assets/lang', file)
+const read = file => readFileSync(file, 'utf8')
+const en = JSON.parse(read(lang('en_US.json')))
+const fr = JSON.parse(read(lang('fr_FR.json')))
+
+// words that are the same in French
+const SAME = ['OK', 'Min', 'Max', 'Direct', 'Machine', 'Total', 'Cache', 'Swap', '{label} (max)', '{hours} h {minutes} min', '{minutes} min', '{seconds} s']
+
+const sources = [
+ ...readdirSync(folder).filter(f => /\.(?:vue|js)$/.test(f) && !f.endsWith('.spec.js')).map(f => join(folder, f)),
+ join(folder, '../../service/stats.js'),
+]
+// $t('key') and t('key'), with any of the three quotes (\x60 is the backtick)
+const CALL = /(?:\$t|\bt)\(\s*(['"\x60])((?:\\.|(?!\1)[^\\])*)\1/g
+const used = new Set([...STATS_KEYS, ...Object.values(SENTENCES).flatMap(s => Object.values(s))])
+for (const file of sources) {
+ for (const [, , key] of read(file).matchAll(CALL)) {
+ used.add(key.replace(/\\(.)/g, '$1'))
+ }
+}
+
+describe('the language keys of the Statistics code', () => {
+ it('finds the keys it is made to find', () => {
+ expect(used.size).toBeGreaterThan(60)
+ expect(used).toContain('Alert settings')
+ expect(used).toContain('Processor of {name} is high')
+ })
+
+ it.each([['en_US', en], ['fr_FR', fr]])('are all in %s', (_, file) => {
+ expect([...used].filter(k => typeof file[k] !== 'string' || file[k] === '')).toEqual([])
+ })
+
+ it('are English for English, French for French', () => {
+ expect([...used].filter(k => en[k] !== k)).toEqual([])
+ expect([...used].filter(k => fr[k] === k && !SAME.includes(k))).toEqual([])
+ })
+
+ it.each(['en_US.json', 'fr_FR.json'])('%s has no key twice', (name) => {
+ const keys = [...read(lang(name)).matchAll(/^ {2}("(?:[^"\\]|\\.)*"): /gm)].map(m => m[1])
+ expect(keys.filter((k, i) => keys.indexOf(k) !== i)).toEqual([])
+ })
+})
diff --git a/src/components/settings/AlertsModal.spec.js b/src/components/settings/AlertsModal.spec.js
index 8e0e7aa4..9387aef8 100644
--- a/src/components/settings/AlertsModal.spec.js
+++ b/src/components/settings/AlertsModal.spec.js
@@ -171,6 +171,32 @@ describe('the alerts dialog', () => {
expect(wrapper.text()).toContain('Not sent: dial tcp: i/o timeout')
})
+ // The fields are stubs without their slots: a row is counted, and the rows' keys are read from the dialog.
+ const rows = wrapper => wrapper.findAll('b-field-stub.mb-2')
+ const RESOURCES = 'A container or a virtual machine uses too much processor or memory, or a virtual machine crashes'
+
+ it('shows the resources category only when the core sends that key, and sends it', async () => {
+ const setAlerts = answer({})
+ const wrapper = await open({
+ getAlerts: answer(view({ categories: { backups: true, disks: true, updates: true, apps: true, resources: true } })),
+ setAlerts,
+ })
+
+ expect(rows(wrapper)).toHaveLength(5)
+ expect(wrapper.vm.shownCategories.map(c => c.key)).toContain('resources')
+ expect(wrapper.vm.shownCategories.find(c => c.key === 'resources').label).toBe(RESOURCES)
+
+ await wrapper.vm.save({ categories: { ...wrapper.vm.view.categories, resources: false } })
+ expect(setAlerts).toHaveBeenLastCalledWith({ categories: { backups: true, disks: true, updates: true, apps: true, resources: false } })
+ })
+
+ it('shows no resources row for a core that does not know the category', async () => {
+ const wrapper = await open({})
+
+ expect(rows(wrapper)).toHaveLength(4)
+ expect(wrapper.vm.shownCategories.map(c => c.key)).not.toContain('resources')
+ })
+
it('sends a category or the threshold alone, and goes back when refused', async () => {
const setAlerts = answer({})
const wrapper = await open({ setAlerts })
diff --git a/src/components/settings/AlertsModal.vue b/src/components/settings/AlertsModal.vue
index 39ed5b44..7ad5873d 100644
--- a/src/components/settings/AlertsModal.vue
+++ b/src/components/settings/AlertsModal.vue
@@ -102,7 +102,7 @@
{{ $t('What to send') }}
-
+ 50 + i),
}
},
computed: {
+ // A core older than the category sends no such key: a box for it would be a switch that does nothing.
+ shownCategories() {
+ return this.categories.filter(category => category.key !== 'resources' || Object.prototype.hasOwnProperty.call(this.view?.categories || {}, 'resources'))
+ },
kind() {
return ALERT_KINDS.find(kind => kind.id === this.draft.kind)
},
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 @@