Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
104759c
feat(stats): clients for the three statistics routes
inkly Oct 20, 2026
9efa5c3
feat(stats): format values and units
inkly Oct 20, 2026
f2bc763
feat(stats): map the statistics series to charts
inkly Oct 20, 2026
3b6083a
feat(stats): a polling chain with one request in flight
inkly Oct 20, 2026
b83e173
fix(stats): plain instance labels, hidden-aware poller, honest last v…
inkly Oct 20, 2026
8cab309
feat(stats): a Statistics system app, lazily loaded
inkly Oct 20, 2026
85bfa62
feat(stats): the page and its list of items
inkly Oct 20, 2026
ae1bbc2
feat(stats): periods, figures and series fetching
inkly Oct 20, 2026
d3be604
fix(stats): stale columns, initial item, gone items, plurals
inkly Oct 20, 2026
29aa6a2
feat(stats): chart cards with shared hover, zoom, legend and table
inkly Oct 20, 2026
bd235af
feat(stats): alert banner and per-item alert settings
inkly Oct 20, 2026
2e1af15
fix(stats): shared crosshair without chart groups, dialog bound to it…
inkly Oct 20, 2026
3a576a2
feat(stats): resource alert notifications and the resources category
inkly Oct 20, 2026
b008969
feat(stats): a count badge on the Statistics icon
inkly Oct 20, 2026
82d239d
fix(stats): the badge reads again after a socket reconnect
inkly Oct 20, 2026
5bb19ad
test(stats): run the page against a stub of the contract
inkly Oct 20, 2026
309455d
test(stats): the language keys of the Statistics code exist in both f…
inkly Oct 20, 2026
a2fe32b
test(stats): scans for HTML sinks and language keys, accessibility ch…
inkly Oct 20, 2026
dfa5c91
fix(stats): a colour per interface, locale numbers, no allocation ter…
inkly Oct 20, 2026
c48dc7f
fix(stats): window size and layout, theme colours, contrast, touch ta…
inkly Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions mock/stats-stub/handlers.js
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 92 additions & 0 deletions mock/stats-stub/server.mjs
Original file line number Diff line number Diff line change
@@ -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'})`)
})
14 changes: 14 additions & 0 deletions src/assets/img/app/stats.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading