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 @@
- + + + + + + + +
+

{{ failure.text }}

+ +
+

{{ note.text }}

+
+ +
+

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

+ +
+
+ + +
+ + + + + + + diff --git a/src/components/Stats/StatsTab.vue b/src/components/Stats/StatsTab.vue new file mode 100644 index 00000000..cbd83dc3 --- /dev/null +++ b/src/components/Stats/StatsTab.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/components/Stats/__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 = '' + }) + + 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 = '') => `\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('