From 80c8b53bbca67d5b8a16d89dbebed2f37d65301b Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:29:12 -0400 Subject: [PATCH 1/2] Feature: read System Summary figures from the Unraid API Adds Unraid as a source alongside Beszel, Glances and the local machine. One GraphQL query returns CPU and memory utilisation, temperatures by sensor name, and the boot time that becomes the uptime. A disk slot names a user share, or is left empty for the array as a whole. Neither IO wait nor a process count exists anywhere in the schema, so the slot does not offer them. Shares and the array sit behind their own permissions, so they are only asked for when a slot needs them, and a key limited to metrics still works. Sensor readings are converted to Celsius, since the API reports whichever unit the server is set to. Temperature reporting ships disabled and resolves to null rather than failing, so the picker names that instead of showing no sensors. --- CHANGELOG.md | 3 +- README.md | 2 +- api/test/system-summary-unraid-data.test.js | 169 ++++++++++++++++++++ ui/widgets/system-summary/data.js | 116 +++++++++++++- ui/widgets/system-summary/i18n/de.json | 10 ++ ui/widgets/system-summary/i18n/en.json | 10 ++ ui/widgets/system-summary/i18n/es.json | 10 ++ ui/widgets/system-summary/i18n/fa.json | 10 ++ ui/widgets/system-summary/i18n/fr.json | 10 ++ ui/widgets/system-summary/i18n/zh-Hans.json | 10 ++ ui/widgets/system-summary/widget.json | 101 ++++++++++++ 11 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 api/test/system-summary-unraid-data.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 597c72d9..324c444c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- System Summary can read its figures from a host running Glances or Beszel. +- System Summary can read its figures from a host running Glances or Beszel, or + from the Unraid API. ### Changed diff --git a/README.md b/README.md index 0e44c6b5..3d1df7b2 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Widgets and the services they read: - **DNS**: AdGuard, Pi-hole, Technitium, NextDNS - **GitHub**: contribution graph and pull requests - **Books**: Audiobookshelf, Komga, Kavita -- **System summary**: CPU, memory, disk, throughput, uptime, and network speed from SpeedTest Tracker or MySpeed. Reports on this machine, or on a host running Glances or Beszel +- **System summary**: CPU, memory, disk, throughput, uptime, and network speed from SpeedTest Tracker or MySpeed. Reports on this machine, or on a host running Glances, Beszel or the Unraid API - **Disk health**: TrueNAS, Scrutiny - **Backup**: Duplicati, Kopia - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami diff --git a/api/test/system-summary-unraid-data.test.js b/api/test/system-summary-unraid-data.test.js new file mode 100644 index 00000000..f7fc1881 --- /dev/null +++ b/api/test/system-summary-unraid-data.test.js @@ -0,0 +1,169 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { normalizeBase } = require('../src/widget-data'); +const { dispatchProvider } = require('../src/provider-dispatch'); +const { errorParts } = require('../test-support/widget-ctx'); + +const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'system-summary', 'data.js')); + +const BOOTED = '2026-08-15T00:00:00.000Z'; + +const REPLY = { + metrics: { + cpu: { percentTotal: 18.5 }, + memory: { percentTotal: 55.25 }, + temperature: { + sensors: [ + { name: 'CPU Package', current: { value: 51, unit: 'CELSIUS' } }, + { name: 'Motherboard', current: { value: 104, unit: 'FAHRENHEIT' } }, + ], + }, + }, + info: { os: { uptime: BOOTED } }, + shares: [ + { name: 'media', size: 8 * 1024 ** 2, used: 2 * 1024 ** 2 }, + { name: 'appdata', size: 1024 ** 2, used: 1024 ** 2 / 4 }, + ], + array: { capacity: { kilobytes: { total: 20 * 1024 ** 2, used: 5 * 1024 ** 2 } } }, +}; + +function ctxFor(config, reply = { status: 200, data: { data: REPLY } }, endpoint = 'system') { + const sent = []; + const ctx = { + endpoint, + config: { statProvider: 'unraid', unraidUrl: 'tower.local', unraidApiKey: 'KEY', ...config }, + normalizeBase, + fetchJSON: async (url, opts) => { + sent.push({ url, opts, query: JSON.parse(opts.body || '{}').query || '' }); + return typeof reply === 'function' ? reply(sent.at(-1)) : reply; + }, + ...errorParts(), + }; + ctx.dispatchProvider = (handlers, opts) => dispatchProvider(ctx, handlers, opts); + return { ctx, sent }; +} + +const SLOTS = [ + { type: 'cpu' }, + { type: 'temp', sensor: 'CPU Package' }, + { type: 'disk', primary: 'media', secondary: '' }, +]; + +test('the request is a POST to /graphql carrying the key', async () => { + const { ctx, sent } = ctxFor({ slots: SLOTS }); + await dataFn(ctx); + assert.equal(sent[0].url, 'http://tower.local/graphql'); + assert.equal(sent[0].opts.method, 'POST'); + assert.equal(sent[0].opts.headers['x-api-key'], 'KEY'); +}); + +test('the metrics map onto the shape the widget already renders', async () => { + const { ctx } = ctxFor({ slots: SLOTS }); + const r = await dataFn(ctx); + assert.equal(r.cpu, 18.5); + assert.equal(r.ram, 55.25); + assert.equal(r.iowait, null, 'Unraid reports no IO wait'); + assert.equal(r.procs, null, 'Unraid reports no process count'); + assert.equal(r.temps['CPU Package'], 51); + assert.equal(r.temps.Motherboard, 40, 'a Fahrenheit sensor is converted'); +}); + +test('a named slot reads a share and a blank one reads the whole array', async () => { + const { ctx } = ctxFor({ slots: [{ type: 'disk', primary: 'media', secondary: 'appdata' }, { type: 'disk' }] }); + const r = await dataFn(ctx); + assert.deepEqual(r.disks, [ + { mount: 'media', usedPct: 25, totalGb: 8 }, + { mount: 'appdata', usedPct: 25, totalGb: 1 }, + { mount: '', usedPct: 25, totalGb: 20 }, + ]); +}); + +test('boot time becomes an elapsed uptime', async () => { + const { ctx } = ctxFor({ slots: SLOTS }); + const r = await dataFn(ctx); + const expected = Math.round((Date.now() - Date.parse(BOOTED)) / 1000); + assert.ok(Math.abs(r.uptime - expected) <= 1, `${r.uptime} should be about ${expected}`); +}); + +test('shares and the array are only asked for when a slot needs them', async () => { + const { ctx, sent } = ctxFor({ slots: [{ type: 'cpu' }, { type: 'ram' }] }); + await dataFn(ctx); + assert.doesNotMatch(sent[0].query, /shares/); + assert.doesNotMatch(sent[0].query, /array/); + assert.doesNotMatch(sent[0].query, /temperature/, 'nor the temperature block'); + + const withDisk = ctxFor({ slots: [{ type: 'disk', primary: 'media' }] }); + await dataFn(withDisk.ctx); + assert.match(withDisk.sent[0].query, /shares/); + assert.doesNotMatch(withDisk.sent[0].query, /array/); +}); + +test('a GraphQL error is reported as written, and a permission problem as an auth failure', async () => { + const denied = ctxFor({ slots: SLOTS }, { status: 200, data: { errors: [{ message: 'Forbidden resource' }] } }); + await assert.rejects(dataFn(denied.ctx), e => { + assert.equal(e.kind, 'auth'); + assert.match(e.message, /Forbidden resource/); + return true; + }); + + const broken = ctxFor({ slots: SLOTS }, { status: 200, data: { errors: [{ message: 'Cannot query field' }] } }); + await assert.rejects(dataFn(broken.ctx), e => { + assert.equal(e.kind, 'upstream'); + return true; + }); +}); + +test('a refused key is an auth failure', async () => { + const { ctx } = ctxFor({ slots: SLOTS }, { status: 401, data: null }); + await assert.rejects(dataFn(ctx), e => { + assert.equal(e.kind, 'auth'); + return true; + }); +}); + +test('the connection details are required before anything is sent', async () => { + for (const config of [{ unraidUrl: '' }, { unraidApiKey: '' }]) { + const { ctx, sent } = ctxFor({ slots: SLOTS, ...config }); + await assert.rejects(dataFn(ctx), e => { + assert.equal(e.kind, 'invalid'); + return true; + }); + assert.equal(sent.length, 0); + } +}); + +test('the sensor picker offers the named sensors', async () => { + const { ctx } = ctxFor({ slots: SLOTS }, undefined, 'sensors'); + const r = await dataFn(ctx); + assert.deepEqual(r.options, [ + { value: 'CPU Package', label: 'CPU Package' }, + { value: 'Motherboard', label: 'Motherboard' }, + ]); +}); + +/* Unraid ships temperature reporting off and answers with null rather than an + error, so an empty picker would read as a broken key. */ +test('temperature reporting left off is named rather than shown as no sensors', async () => { + const { ctx } = ctxFor( + { slots: SLOTS }, + { status: 200, data: { data: { metrics: { temperature: null } } } }, + 'sensors', + ); + await assert.rejects(dataFn(ctx), e => { + assert.equal(e.kind, 'invalid'); + assert.match(e.message, /Turn on temperature reporting/); + return true; + }); +}); + +test('a temperature slot still renders when reporting is off', async () => { + const reply = { + status: 200, + data: { data: { metrics: { cpu: { percentTotal: 1 }, temperature: null }, info: { os: {} } } }, + }; + const { ctx } = ctxFor({ slots: [{ type: 'temp', sensor: 'CPU Package' }] }, reply); + const r = await dataFn(ctx); + assert.deepEqual(r.temps, {}); + assert.equal(r.uptime, null); +}); diff --git a/ui/widgets/system-summary/data.js b/ui/widgets/system-summary/data.js index 30c2eb18..529dc1ec 100644 --- a/ui/widgets/system-summary/data.js +++ b/ui/widgets/system-summary/data.js @@ -11,6 +11,7 @@ function systemSummary(ctx) { system: systemSummaryLocal, glances: systemSummaryGlances, beszel: systemSummaryBeszel, + unraid: systemSummaryUnraid, }, { field: 'statProvider', default: 'system' }, ); @@ -20,7 +21,7 @@ function systemSummary(ctx) { has no handler here. */ function sensorOptions(ctx) { return ctx.dispatchProvider( - { glances: glancesSensorOptions, beszel: beszelSensorOptions }, + { glances: glancesSensorOptions, beszel: beszelSensorOptions, unraid: unraidSensorOptions }, { field: 'statProvider' }, ); } @@ -344,3 +345,116 @@ async function systemSummaryBeszel(ctx) { uptime: Number(record?.info?.u) || null, }; } + +/* Unraid's own web server proxies the API at /graphql, and refuses GET unless + its sandbox is on, so every request here is a POST carrying the key. */ +async function unraidQuery(ctx, query) { + const { config, fetchJSON, normalizeBase } = ctx; + if (!config.unraidUrl) ctx.fail('Enter the Unraid URL first.', { kind: ctx.KIND.INVALID }); + if (!config.unraidApiKey) ctx.fail('Enter the Unraid API key first.', { kind: ctx.KIND.INVALID }); + const r = await fetchJSON(normalizeBase(config.unraidUrl) + '/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': config.unraidApiKey }, + body: JSON.stringify({ query }), + timeout: 8000, + }); + if (r.status === 401 || r.status === 403) ctx.fail('Unraid rejected the API key', { kind: ctx.KIND.AUTH }); + if (r.status >= 400) ctx.fail('Unraid HTTP ' + r.status); + const problem = r.data?.errors?.[0]?.message; + /* A key without the right role fails here, with 200 and an errors array. */ + if (problem) + ctx.fail('Unraid: ' + problem, { + kind: /permission|forbidden|unauthor/i.test(problem) ? ctx.KIND.AUTH : ctx.KIND.UPSTREAM, + }); + if (!r.data?.data) ctx.fail('Unraid returned no data'); + return r.data.data; +} + +const UNRAID_TEMPS = 'temperature { sensors { name current { value unit } } }'; + +function unraidCelsius(reading) { + const v = Number(reading?.value); + if (!Number.isFinite(v)) return null; + switch (reading.unit) { + case 'FAHRENHEIT': + return ((v - 32) * 5) / 9; + case 'KELVIN': + return v - 273.15; + case 'RANKINE': + return ((v - 491.67) * 5) / 9; + default: + return v; + } +} + +function unraidTemps(temperature) { + const out = {}; + for (const s of temperature?.sensors || []) { + const c = unraidCelsius(s?.current); + if (s?.name && c !== null) out[s.name] = c; + } + return out; +} + +async function unraidSensorOptions(ctx) { + const data = await unraidQuery(ctx, `{ metrics { ${UNRAID_TEMPS} } }`); + const temperature = data.metrics?.temperature; + /* Unraid ships temperature reporting off, and resolves the field to null + rather than failing, so an empty picker would look like a broken key. */ + if (!temperature) ctx.fail('Turn on temperature reporting in Unraid first.', { kind: ctx.KIND.INVALID }); + const temps = unraidTemps(temperature); + return { options: Object.keys(temps).map(name => ({ value: name, label: name })) }; +} + +const KB = 1024; +const kbToGb = v => (Number(v) || 0) / KB ** 2; + +/* A named slot is a user share. A blank one is the array as a whole. */ +function unraidDisk(data, name) { + if (!name) { + const kb = data.array?.capacity?.kilobytes || {}; + const total = Number(kb.total) || 0; + const used = Number(kb.used) || 0; + return { usedPct: total > 0 ? (used / total) * 100 : 0, totalGb: kbToGb(total) }; + } + const share = (data.shares || []).find(s => s?.name === name); + const total = Number(share?.size) || 0; + const used = Number(share?.used) || 0; + return { usedPct: total > 0 ? (used / total) * 100 : 0, totalGb: kbToGb(total) }; +} + +async function systemSummaryUnraid(ctx) { + const slots = ctx.config.slots || []; + const wants = type => slots.some(s => s.type === type); + const mounts = new Set(); + for (const s of slots) { + if (s.type !== 'disk') continue; + mounts.add(s.primary || ''); + if (s.secondary) mounts.add(s.secondary); + } + + /* Shares and the array sit behind their own permissions, so they are only + asked for when a slot needs them. */ + const parts = [`metrics { cpu { percentTotal } memory { percentTotal }${wants('temp') ? ' ' + UNRAID_TEMPS : ''} }`]; + parts.push('info { os { uptime } }'); + if ([...mounts].some(m => m)) parts.push('shares { name size used }'); + if (mounts.has('')) parts.push('array { capacity { kilobytes { total used } } }'); + + const data = await unraidQuery(ctx, `{ ${parts.join(' ')} }`); + const temps = unraidTemps(data.metrics?.temperature); + + /* info.os.uptime is the boot time, not an elapsed count. */ + const booted = Date.parse(data.info?.os?.uptime); + const uptime = Number.isFinite(booted) ? Math.max(0, Math.round((Date.now() - booted) / 1000)) : null; + + return { + cpu: typeof data.metrics?.cpu?.percentTotal === 'number' ? data.metrics.cpu.percentTotal : null, + ram: typeof data.metrics?.memory?.percentTotal === 'number' ? data.metrics.memory.percentTotal : null, + temp: Object.values(temps)[0] ?? null, + temps, + disks: [...mounts].map(mount => ({ mount, ...unraidDisk(data, mount) })), + iowait: null, + procs: null, + uptime, + }; +} diff --git a/ui/widgets/system-summary/i18n/de.json b/ui/widgets/system-summary/i18n/de.json index addd633b..c890cf0d 100644 --- a/ui/widgets/system-summary/i18n/de.json +++ b/ui/widgets/system-summary/i18n/de.json @@ -4,6 +4,7 @@ "statProvider.opt.system": "Dieser Rechner", "statProvider.opt.glances": "Glances", "statProvider.opt.beszel": "Beszel", + "statProvider.opt.unraid": "Unraid", "glancesUrl.label": "Glances-URL", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Benutzername", @@ -15,6 +16,9 @@ "beszelPass.label": "Passwort", "beszelSystem.label": "System", "beszelSystem.hint": "Oben URL und Konto eintragen, dann die Liste laden.", + "unraidUrl.label": "Unraid-URL", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Unraid-API-Schlüssel", "slots.label": "Slot", "slots.type.label": "Ressource", "slots.type.opt.cpu": "CPU", @@ -31,11 +35,17 @@ "slots.fsPrimary.placeholder": "root", "slots.fsSecondary.label": "Zweites Dateisystem", "slots.fsSecondary.placeholder": "media", + "slots.sharePrimary.label": "Erste Freigabe", + "slots.sharePrimary.placeholder": "media", + "slots.shareSecondary.label": "Zweite Freigabe", + "slots.shareSecondary.placeholder": "appdata", + "slots.share.hint": "Leer lassen für das gesamte Array.", "slots.thermalZone.label": "Thermalzone", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Oben die URL eintragen, dann die Liste laden.", "slots.color.label": "Farbe", "slots.beszelHint": "Beszel meldet keine Prozessanzahl.", + "slots.unraidHint": "Unraid meldet weder IO-Wartezeit noch Prozessanzahl.", "network.label": "Netzwerk", "network.enabled.label": "Netzwerkzeile anzeigen", "network.mode.label": "Modus", diff --git a/ui/widgets/system-summary/i18n/en.json b/ui/widgets/system-summary/i18n/en.json index 122976e3..bf3cfbc1 100644 --- a/ui/widgets/system-summary/i18n/en.json +++ b/ui/widgets/system-summary/i18n/en.json @@ -4,6 +4,7 @@ "statProvider.opt.system": "This Machine", "statProvider.opt.glances": "Glances", "statProvider.opt.beszel": "Beszel", + "statProvider.opt.unraid": "Unraid", "glancesUrl.label": "Glances URL", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Username", @@ -15,6 +16,9 @@ "beszelPass.label": "Password", "beszelSystem.label": "System", "beszelSystem.hint": "Enter the URL and account above, then load the list.", + "unraidUrl.label": "Unraid URL", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Unraid API Key", "slots.label": "Slot", "slots.type.label": "Resource", "slots.type.opt.cpu": "CPU", @@ -31,11 +35,17 @@ "slots.fsPrimary.placeholder": "root", "slots.fsSecondary.label": "Second Filesystem", "slots.fsSecondary.placeholder": "media", + "slots.sharePrimary.label": "First Share", + "slots.sharePrimary.placeholder": "media", + "slots.shareSecondary.label": "Second Share", + "slots.shareSecondary.placeholder": "appdata", + "slots.share.hint": "Leave empty for the whole array.", "slots.thermalZone.label": "Thermal Zone", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Enter the URL above, then load the list.", "slots.color.label": "Color", "slots.beszelHint": "Beszel does not report a process count.", + "slots.unraidHint": "Unraid does not report IO wait or a process count.", "network.label": "Network", "network.enabled.label": "Show Network Row", "network.mode.label": "Mode", diff --git a/ui/widgets/system-summary/i18n/es.json b/ui/widgets/system-summary/i18n/es.json index d3bba69f..c9226b5e 100644 --- a/ui/widgets/system-summary/i18n/es.json +++ b/ui/widgets/system-summary/i18n/es.json @@ -4,6 +4,7 @@ "statProvider.opt.system": "Este equipo", "statProvider.opt.glances": "Glances", "statProvider.opt.beszel": "Beszel", + "statProvider.opt.unraid": "Unraid", "glancesUrl.label": "URL de Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Usuario", @@ -15,6 +16,9 @@ "beszelPass.label": "Contraseña", "beszelSystem.label": "Sistema", "beszelSystem.hint": "Introduce la URL y la cuenta de arriba y carga la lista.", + "unraidUrl.label": "URL de Unraid", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Clave de API de Unraid", "slots.label": "Ranura", "slots.type.label": "Recurso", "slots.type.opt.cpu": "CPU", @@ -31,11 +35,17 @@ "slots.fsPrimary.placeholder": "root", "slots.fsSecondary.label": "Segundo sistema de archivos", "slots.fsSecondary.placeholder": "media", + "slots.sharePrimary.label": "Primer recurso", + "slots.sharePrimary.placeholder": "media", + "slots.shareSecondary.label": "Segundo recurso", + "slots.shareSecondary.placeholder": "appdata", + "slots.share.hint": "Déjalo vacío para todo el array.", "slots.thermalZone.label": "Zona térmica", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Introduce la URL de arriba y carga la lista.", "slots.color.label": "Color", "slots.beszelHint": "Beszel no informa del número de procesos.", + "slots.unraidHint": "Unraid no informa de la espera de E/S ni del número de procesos.", "network.label": "Red", "network.enabled.label": "Mostrar la fila de red", "network.mode.label": "Modo", diff --git a/ui/widgets/system-summary/i18n/fa.json b/ui/widgets/system-summary/i18n/fa.json index 10b5ee7a..67a27eaa 100644 --- a/ui/widgets/system-summary/i18n/fa.json +++ b/ui/widgets/system-summary/i18n/fa.json @@ -4,6 +4,7 @@ "statProvider.opt.system": "همین دستگاه", "statProvider.opt.glances": "Glances", "statProvider.opt.beszel": "Beszel", + "statProvider.opt.unraid": "Unraid", "glancesUrl.label": "نشانی Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "نام کاربری", @@ -15,6 +16,9 @@ "beszelPass.label": "گذرواژه", "beszelSystem.label": "سامانه", "beszelSystem.hint": "نشانی و حساب بالا را وارد کنید، سپس فهرست را بارگیری کنید.", + "unraidUrl.label": "نشانی Unraid", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "کلید API آنرید", "slots.label": "جایگاه", "slots.type.label": "منبع", "slots.type.opt.cpu": "CPU", @@ -31,11 +35,17 @@ "slots.fsPrimary.placeholder": "root", "slots.fsSecondary.label": "دومین سامانهٔ پرونده", "slots.fsSecondary.placeholder": "media", + "slots.sharePrimary.label": "نخستین اشتراک", + "slots.sharePrimary.placeholder": "media", + "slots.shareSecondary.label": "دومین اشتراک", + "slots.shareSecondary.placeholder": "appdata", + "slots.share.hint": "برای کل آرایه خالی بگذارید.", "slots.thermalZone.label": "ناحیهٔ حرارتی", "slots.sensor.label": "حسگر", "slots.sensor.hint": "نشانی بالا را وارد کنید، سپس فهرست را بارگیری کنید.", "slots.color.label": "رنگ", "slots.beszelHint": "Beszel شمار فرایندها را گزارش نمی‌کند.", + "slots.unraidHint": "Unraid زمان انتظار ورودی/خروجی و شمار فرایندها را گزارش نمی‌کند.", "network.label": "شبکه", "network.enabled.label": "نمایش ردیف شبکه", "network.mode.label": "حالت", diff --git a/ui/widgets/system-summary/i18n/fr.json b/ui/widgets/system-summary/i18n/fr.json index f30ce3ac..d9f5a21d 100644 --- a/ui/widgets/system-summary/i18n/fr.json +++ b/ui/widgets/system-summary/i18n/fr.json @@ -4,6 +4,7 @@ "statProvider.opt.system": "Cette machine", "statProvider.opt.glances": "Glances", "statProvider.opt.beszel": "Beszel", + "statProvider.opt.unraid": "Unraid", "glancesUrl.label": "URL Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Nom d'utilisateur", @@ -15,6 +16,9 @@ "beszelPass.label": "Mot de passe", "beszelSystem.label": "Système", "beszelSystem.hint": "Saisissez l'URL et le compte ci-dessus, puis chargez la liste.", + "unraidUrl.label": "URL Unraid", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Clé API Unraid", "slots.label": "Emplacement", "slots.type.label": "Ressource", "slots.type.opt.cpu": "CPU", @@ -31,11 +35,17 @@ "slots.fsPrimary.placeholder": "root", "slots.fsSecondary.label": "Deuxième système de fichiers", "slots.fsSecondary.placeholder": "media", + "slots.sharePrimary.label": "Premier partage", + "slots.sharePrimary.placeholder": "media", + "slots.shareSecondary.label": "Deuxième partage", + "slots.shareSecondary.placeholder": "appdata", + "slots.share.hint": "Laissez vide pour la grappe entière.", "slots.thermalZone.label": "Zone thermique", "slots.sensor.label": "Capteur", "slots.sensor.hint": "Saisissez l'URL ci-dessus, puis chargez la liste.", "slots.color.label": "Couleur", "slots.beszelHint": "Beszel ne rapporte pas le nombre de processus.", + "slots.unraidHint": "Unraid ne rapporte ni l'attente d'E/S ni le nombre de processus.", "network.label": "Réseau", "network.enabled.label": "Afficher la ligne réseau", "network.mode.label": "Mode", diff --git a/ui/widgets/system-summary/i18n/zh-Hans.json b/ui/widgets/system-summary/i18n/zh-Hans.json index 425fb47a..41faa9e1 100644 --- a/ui/widgets/system-summary/i18n/zh-Hans.json +++ b/ui/widgets/system-summary/i18n/zh-Hans.json @@ -4,6 +4,7 @@ "statProvider.opt.system": "本机", "statProvider.opt.glances": "Glances", "statProvider.opt.beszel": "Beszel", + "statProvider.opt.unraid": "Unraid", "glancesUrl.label": "Glances 地址", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "用户名", @@ -15,6 +16,9 @@ "beszelPass.label": "密码", "beszelSystem.label": "系统", "beszelSystem.hint": "先填写上方地址和账户,然后加载列表。", + "unraidUrl.label": "Unraid 地址", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Unraid API 密钥", "slots.label": "插槽", "slots.type.label": "资源", "slots.type.opt.cpu": "CPU", @@ -31,11 +35,17 @@ "slots.fsPrimary.placeholder": "root", "slots.fsSecondary.label": "第二个文件系统", "slots.fsSecondary.placeholder": "media", + "slots.sharePrimary.label": "第一个共享", + "slots.sharePrimary.placeholder": "media", + "slots.shareSecondary.label": "第二个共享", + "slots.shareSecondary.placeholder": "appdata", + "slots.share.hint": "留空表示整个阵列。", "slots.thermalZone.label": "温度区域", "slots.sensor.label": "传感器", "slots.sensor.hint": "先填写上方地址,然后加载列表。", "slots.color.label": "颜色", "slots.beszelHint": "Beszel 不报告进程数。", + "slots.unraidHint": "Unraid 不报告 IO 等待和进程数。", "network.label": "网络", "network.enabled.label": "显示网络行", "network.mode.label": "模式", diff --git a/ui/widgets/system-summary/widget.json b/ui/widgets/system-summary/widget.json index de61fb1e..bf908e0f 100644 --- a/ui/widgets/system-summary/widget.json +++ b/ui/widgets/system-summary/widget.json @@ -24,6 +24,10 @@ { "value": "beszel", "label": "statProvider.opt.beszel" + }, + { + "value": "unraid", + "label": "statProvider.opt.unraid" } ] }, @@ -97,6 +101,25 @@ "equals": "beszel" } }, + { + "key": "unraidUrl", + "type": "text", + "label": "unraidUrl.label", + "placeholder": "unraidUrl.placeholder", + "showIf": { + "field": "statProvider", + "equals": "unraid" + } + }, + { + "key": "unraidApiKey", + "type": "secret", + "label": "unraidApiKey.label", + "showIf": { + "field": "statProvider", + "equals": "unraid" + } + }, { "key": "slots", "type": "group", @@ -345,6 +368,84 @@ }, "hint": "slots.beszelHint" }, + { + "key": "slots", + "type": "group", + "label": "slots.label", + "min": 3, + "max": 3, + "fields": [ + { + "key": "type", + "type": "select", + "label": "slots.type.label", + "default": "cpu", + "options": [ + { + "value": "cpu", + "label": "slots.type.opt.cpu" + }, + { + "value": "ram", + "label": "slots.type.opt.ram" + }, + { + "value": "temp", + "label": "slots.type.opt.temp" + }, + { + "value": "disk", + "label": "slots.type.opt.disk" + } + ] + }, + { + "key": "primary", + "type": "text", + "label": "slots.sharePrimary.label", + "default": "/", + "placeholder": "slots.sharePrimary.placeholder", + "showIf": { + "field": "type", + "equals": "disk" + }, + "hint": "slots.share.hint" + }, + { + "key": "secondary", + "type": "text", + "label": "slots.shareSecondary.label", + "placeholder": "slots.shareSecondary.placeholder", + "optional": true, + "showIf": { + "field": "type", + "equals": "disk" + } + }, + { + "key": "sensor", + "type": "select", + "label": "slots.sensor.label", + "optionsFrom": "sensors", + "hint": "slots.sensor.hint", + "showIf": { + "field": "type", + "equals": "temp" + } + }, + { + "key": "color", + "type": "color", + "label": "slots.color.label", + "default": "#0091FF" + } + ], + "showIf": { + "field": "statProvider", + "equals": "unraid" + }, + "hint": "slots.unraidHint" + }, { "key": "network", "type": "object", From 182f3abf0de8b019b5dc186bda1c70f9870aa48c Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:53:23 -0400 Subject: [PATCH 2/2] Fix: read Unraid share sizes and auth failures the way the API reports them A share's size field is its own limit and is 0 unless one is set, so a share was measured against nothing and always read as empty. What is used plus what is left on the pool is the only total available, and is now the fallback. An API key the server refuses does not produce a 401. The request succeeds and the rejection arrives as a GraphQL error, so the status code alone never reports it. The error's own code now decides, covering both a refused key and a key whose role does not reach the resource. --- api/test/system-summary-unraid-data.test.js | 47 +++++++++++++-------- ui/widgets/system-summary/data.js | 24 +++++++---- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/api/test/system-summary-unraid-data.test.js b/api/test/system-summary-unraid-data.test.js index f7fc1881..d05193bf 100644 --- a/api/test/system-summary-unraid-data.test.js +++ b/api/test/system-summary-unraid-data.test.js @@ -22,10 +22,12 @@ const REPLY = { }, info: { os: { uptime: BOOTED } }, shares: [ - { name: 'media', size: 8 * 1024 ** 2, used: 2 * 1024 ** 2 }, - { name: 'appdata', size: 1024 ** 2, used: 1024 ** 2 / 4 }, + { name: 'media', size: 8 * 1024 ** 2, used: 2 * 1024 ** 2, free: 6 * 1024 ** 2 }, + /* No size limit set, which is the usual case, and what a real server returns. */ + { name: 'appdata', size: 0, used: 1024 ** 2, free: 3 * 1024 ** 2 }, ], - array: { capacity: { kilobytes: { total: 20 * 1024 ** 2, used: 5 * 1024 ** 2 } } }, + /* BigInt fields come back as strings. */ + array: { capacity: { kilobytes: { total: String(20 * 1024 ** 2), used: String(5 * 1024 ** 2) } } }, }; function ctxFor(config, reply = { status: 200, data: { data: REPLY } }, endpoint = 'system') { @@ -74,7 +76,7 @@ test('a named slot reads a share and a blank one reads the whole array', async ( const r = await dataFn(ctx); assert.deepEqual(r.disks, [ { mount: 'media', usedPct: 25, totalGb: 8 }, - { mount: 'appdata', usedPct: 25, totalGb: 1 }, + { mount: 'appdata', usedPct: 25, totalGb: 4 }, { mount: '', usedPct: 25, totalGb: 20 }, ]); }); @@ -86,6 +88,12 @@ test('boot time becomes an elapsed uptime', async () => { assert.ok(Math.abs(r.uptime - expected) <= 1, `${r.uptime} should be about ${expected}`); }); +test('a share with no size limit is measured against what is left on the pool', async () => { + const { ctx } = ctxFor({ slots: [{ type: 'disk', primary: 'appdata' }] }); + const r = await dataFn(ctx); + assert.deepEqual(r.disks, [{ mount: 'appdata', usedPct: 25, totalGb: 4 }], 'used + free is the only total there is'); +}); + test('shares and the array are only asked for when a slot needs them', async () => { const { ctx, sent } = ctxFor({ slots: [{ type: 'cpu' }, { type: 'ram' }] }); await dataFn(ctx); @@ -99,19 +107,24 @@ test('shares and the array are only asked for when a slot needs them', async () assert.doesNotMatch(withDisk.sent[0].query, /array/); }); -test('a GraphQL error is reported as written, and a permission problem as an auth failure', async () => { - const denied = ctxFor({ slots: SLOTS }, { status: 200, data: { errors: [{ message: 'Forbidden resource' }] } }); - await assert.rejects(dataFn(denied.ctx), e => { - assert.equal(e.kind, 'auth'); - assert.match(e.message, /Forbidden resource/); - return true; - }); - - const broken = ctxFor({ slots: SLOTS }, { status: 200, data: { errors: [{ message: 'Cannot query field' }] } }); - await assert.rejects(dataFn(broken.ctx), e => { - assert.equal(e.kind, 'upstream'); - return true; - }); +/* Neither a refused key nor a key without the role answers 401: both come back + as 200 with an errors array, so only the error's own code separates an auth + problem from a broken query. */ +test('an auth problem is read from the GraphQL error code, not the status', async () => { + const cases = [ + [{ message: 'Forbidden resource', extensions: { code: 'FORBIDDEN' } }, 'auth'], + [{ message: 'API key validation failed', extensions: { code: 'UNAUTHENTICATED' } }, 'auth'], + [{ message: 'Cannot query field "nope"', extensions: { code: 'GRAPHQL_VALIDATION_FAILED' } }, 'upstream'], + [{ message: 'Forbidden resource' }, 'auth'], + ]; + for (const [error, kind] of cases) { + const { ctx } = ctxFor({ slots: SLOTS }, { status: 200, data: { errors: [error] } }); + await assert.rejects(dataFn(ctx), e => { + assert.equal(e.kind, kind, error.message); + assert.match(e.message, new RegExp(error.message.split('"')[0])); + return true; + }); + } }); test('a refused key is an auth failure', async () => { diff --git a/ui/widgets/system-summary/data.js b/ui/widgets/system-summary/data.js index 529dc1ec..3b90dc9f 100644 --- a/ui/widgets/system-summary/data.js +++ b/ui/widgets/system-summary/data.js @@ -360,12 +360,17 @@ async function unraidQuery(ctx, query) { }); if (r.status === 401 || r.status === 403) ctx.fail('Unraid rejected the API key', { kind: ctx.KIND.AUTH }); if (r.status >= 400) ctx.fail('Unraid HTTP ' + r.status); - const problem = r.data?.errors?.[0]?.message; - /* A key without the right role fails here, with 200 and an errors array. */ - if (problem) - ctx.fail('Unraid: ' + problem, { - kind: /permission|forbidden|unauthor/i.test(problem) ? ctx.KIND.AUTH : ctx.KIND.UPSTREAM, - }); + /* Both a refused key and a key without the role answer 200 with an errors + array, so the status code alone never reports an auth problem. */ + const problem = r.data?.errors?.[0]; + if (problem) { + const code = problem.extensions?.code; + const denied = + code === 'FORBIDDEN' || + code === 'UNAUTHENTICATED' || + /permission|forbidden|unauthor|api key/i.test(problem.message || ''); + ctx.fail('Unraid: ' + problem.message, { kind: denied ? ctx.KIND.AUTH : ctx.KIND.UPSTREAM }); + } if (!r.data?.data) ctx.fail('Unraid returned no data'); return r.data.data; } @@ -418,8 +423,11 @@ function unraidDisk(data, name) { return { usedPct: total > 0 ? (used / total) * 100 : 0, totalGb: kbToGb(total) }; } const share = (data.shares || []).find(s => s?.name === name); - const total = Number(share?.size) || 0; const used = Number(share?.used) || 0; + /* size is the share's own limit, and is 0 when none is set, which is the + usual case. What is left on the pool is then the only total there is. */ + const limit = Number(share?.size) || 0; + const total = limit > 0 ? limit : used + (Number(share?.free) || 0); return { usedPct: total > 0 ? (used / total) * 100 : 0, totalGb: kbToGb(total) }; } @@ -437,7 +445,7 @@ async function systemSummaryUnraid(ctx) { asked for when a slot needs them. */ const parts = [`metrics { cpu { percentTotal } memory { percentTotal }${wants('temp') ? ' ' + UNRAID_TEMPS : ''} }`]; parts.push('info { os { uptime } }'); - if ([...mounts].some(m => m)) parts.push('shares { name size used }'); + if ([...mounts].some(m => m)) parts.push('shares { name size used free }'); if (mounts.has('')) parts.push('array { capacity { kilobytes { total used } } }'); const data = await unraidQuery(ctx, `{ ${parts.join(' ')} }`);