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..d05193bf --- /dev/null +++ b/api/test/system-summary-unraid-data.test.js @@ -0,0 +1,182 @@ +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, 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 }, + ], + /* 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') { + 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: 4 }, + { 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('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); + 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/); +}); + +/* 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 () => { + 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..3b90dc9f 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,124 @@ 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); + /* 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; +} + +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 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) }; +} + +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 free }'); + 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",