From 4573fb5485cf38c11ea587da14b7b91b30f7ba1c Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:14:11 -0400 Subject: [PATCH 1/2] Add Unraid as a Disk Health source Read parity, data and cache drive states and temperatures from the Unraid API array query. Disabled, missing, wrong and invalid drives show as failed. --- README.md | 2 +- api/test/disk-health-data.test.js | 100 +++++++++++++++++++++++ ui/widgets/disk-health/data.js | 88 ++++++++++++++++++++ ui/widgets/disk-health/i18n/de.json | 8 ++ ui/widgets/disk-health/i18n/en.json | 8 ++ ui/widgets/disk-health/i18n/es.json | 8 ++ ui/widgets/disk-health/i18n/fa.json | 8 ++ ui/widgets/disk-health/i18n/fr.json | 8 ++ ui/widgets/disk-health/i18n/zh-Hans.json | 8 ++ ui/widgets/disk-health/widget.json | 37 +++++++++ 10 files changed, 274 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bcb8fdc8..59ab243d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Widgets and the services they read: - **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, Beszel, the Unraid API or Proxmox VE -- **Disk health**: TrueNAS, Scrutiny +- **Disk health**: TrueNAS, Unraid, Scrutiny - **Backup**: Duplicati, Kopia - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami - **Dashboard switch**: links to your other Stackyard dashboards diff --git a/api/test/disk-health-data.test.js b/api/test/disk-health-data.test.js index 360041df..a0d205ed 100644 --- a/api/test/disk-health-data.test.js +++ b/api/test/disk-health-data.test.js @@ -174,3 +174,103 @@ for (const endpoint of ['', 'devices']) { assert.deepEqual(seen, [{ Authorization: 'Bearer tok' }, {}]); }); } + +const UNRAID = { diskProvider: 'unraid', unraidUrl: 'http://tower.local', unraidApiKey: 'key' }; + +const unraidSlot = (name, status, extra = {}) => ({ + name, + device: 'sd' + name.at(-1), + size: 1000, + status, + temp: 30, + ...extra, +}); + +function unraidFetch(array, seen = []) { + return async (url, opts) => { + seen.push({ url, opts }); + return { status: 200, data: { data: { array } } }; + }; +} + +test('the Unraid bay picker lists parity, data and cache drives and skips unassigned slots', async () => { + const array = { + parities: [unraidSlot('parity', 'DISK_OK')], + disks: [unraidSlot('disk1', 'DISK_OK'), { name: 'disk2', device: '', status: 'DISK_NP' }], + caches: [unraidSlot('cache', 'DISK_OK', { device: 'nvme0n1' })], + }; + const r = await dataFn(makeCtx('devices', UNRAID, unraidFetch(array))); + assert.deepEqual(r.options, [ + { value: 'parity', label: 'parity (sdy)' }, + { value: 'disk1', label: 'disk1 (sd1)' }, + { value: 'cache', label: 'cache (nvme0n1)' }, + ]); +}); + +for (const endpoint of ['', 'devices']) { + test(`${endpoint || 'the bay list'} posts the array query to Unraid with the key`, async () => { + const seen = []; + await dataFn(makeCtx(endpoint, { ...UNRAID, bays: [] }, unraidFetch({}, seen))); + assert.equal(seen[0].url, 'http://tower.local/graphql'); + assert.equal(seen[0].opts.method, 'POST'); + assert.equal(seen[0].opts.headers['x-api-key'], 'key'); + assert.match(JSON.parse(seen[0].opts.body).query, /array \{ parities/); + }); +} + +for (const [status, health] of [ + ['DISK_OK', 'healthy'], + ['DISK_NEW', 'healthy'], + ['DISK_NP_MISSING', 'failed'], + ['DISK_INVALID', 'failed'], + ['DISK_WRONG', 'failed'], + ['DISK_DSBL', 'failed'], + ['DISK_NP_DSBL', 'failed'], + ['DISK_DSBL_NEW', 'failed'], + ['SOMETHING_NEW', 'unknown'], + [null, 'unknown'], +]) { + test(`Unraid state ${status} is a ${health} drive`, async () => { + const array = { disks: [unraidSlot('disk1', status)] }; + const [bay] = (await dataFn(makeCtx('', { ...UNRAID, bays: ['disk1'] }, unraidFetch(array)))).bays; + assert.equal(bay.hasSmart, health !== 'unknown'); + if (health !== 'unknown') assert.equal(bay.device_status, health === 'failed' ? 2 : 0); + }); +} + +test('an Unraid drive reports its size in bytes and no temperature while spun down', async () => { + const array = { disks: [unraidSlot('disk1', 'DISK_OK', { size: 1953514552, temp: null })] }; + const [bay] = (await dataFn(makeCtx('', { ...UNRAID, bays: ['disk1'] }, unraidFetch(array)))).bays; + assert.equal(bay.capacity, 1953514552 * 1024); + assert.equal(bay.temp, null); + assert.equal(bay.device_name, 'disk1'); +}); + +test('an Unraid bay whose slot is now unassigned is reported as not found', async () => { + const array = { disks: [{ name: 'disk1', device: '', status: 'DISK_NP' }] }; + const [bay] = (await dataFn(makeCtx('', { ...UNRAID, bays: ['disk1'] }, unraidFetch(array)))).bays; + assert.equal(bay.error, 'not found'); +}); + +test('a key without array access is reported as an auth failure', async () => { + const fetchJSON = async () => ({ + status: 200, + data: { errors: [{ message: 'Forbidden resource', extensions: { code: 'FORBIDDEN' } }], data: null }, + }); + await assert.rejects(dataFn(makeCtx('', { ...UNRAID, bays: ['disk1'] }, fetchJSON)), { kind: 'auth' }); +}); + +test('the Unraid picker asks for the key before calling the server', async () => { + let called = false; + const fetchJSON = async () => { + called = true; + }; + await assert.rejects(dataFn(makeCtx('devices', { ...UNRAID, unraidApiKey: '' }, fetchJSON)), { kind: 'invalid' }); + assert.equal(called, false); +}); + +test('with no link set the widget opens the Unraid address the browser can reach', async () => { + const r = await dataFn(makeCtx('', { ...UNRAID, unraidUrl: 'http://10.0.0.9', bays: [] }, unraidFetch({}))); + assert.equal(r.href, 'http://10.0.0.9'); + assert.equal(r.provider, 'unraid'); +}); diff --git a/ui/widgets/disk-health/data.js b/ui/widgets/disk-health/data.js index 2f7473f7..d697c7dc 100644 --- a/ui/widgets/disk-health/data.js +++ b/ui/widgets/disk-health/data.js @@ -8,6 +8,7 @@ function diskDevices(ctx) { { scrutiny: scrutinyDeviceOptions, truenas: truenasPoolOptions, + unraid: unraidDiskOptions, }, { field: 'diskProvider', default: 'scrutiny' }, ); @@ -85,6 +86,7 @@ function diskHealth(ctx) { { scrutiny: diskHealthScrutiny, truenas: diskHealthTrueNas, + unraid: diskHealthUnraid, }, { field: 'diskProvider', default: 'scrutiny' }, ); @@ -166,3 +168,89 @@ async function diskHealthTrueNas(ctx) { return { bays: result, href: ctx.serviceLink(config.truenasHref, url), provider: 'truenas' }; } + +/* 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 || ''); + if (denied) ctx.fail('Unraid refused this API key or its role', { kind: ctx.KIND.AUTH }); + ctx.fail('Unraid could not answer the query'); + } + if (!r.data?.data) ctx.fail('Unraid returned no data'); + return r.data.data; +} + +const UNRAID_DISK = 'name device size status temp'; +const UNRAID_ARRAY = `{ array { parities { ${UNRAID_DISK} } disks { ${UNRAID_DISK} } caches { ${UNRAID_DISK} } } }`; + +async function unraidSlots(ctx) { + const array = (await unraidQuery(ctx, UNRAID_ARRAY)).array || {}; + return [...(array.parities || []), ...(array.disks || []), ...(array.caches || [])].filter( + d => d && typeof d.name === 'string' && d.name && d.status !== 'DISK_NP', + ); +} + +async function unraidDiskOptions(ctx) { + const options = (await unraidSlots(ctx)).map(d => ({ + value: d.name, + label: d.device ? `${d.name} (${d.device})` : d.name, + })); + return { options }; +} + +const UNRAID_HEALTHY = new Set(['DISK_OK', 'DISK_NEW']); +const UNRAID_FAILED = new Set([ + 'DISK_NP_MISSING', + 'DISK_INVALID', + 'DISK_WRONG', + 'DISK_DSBL', + 'DISK_NP_DSBL', + 'DISK_DSBL_NEW', +]); + +async function diskHealthUnraid(ctx) { + const { config } = ctx; + if (!config.unraidUrl) ctx.fail('unraidUrl not configured', { kind: ctx.KIND.INVALID }); + const bays = config.bays || []; + + const byName = {}; + for (const d of await unraidSlots(ctx)) byName[d.name] = d; + + const result = bays.map(name => { + if (!name) return null; + const d = byName[name]; + if (!d) return { device_id: name, device_status: 0, hasSmart: false, error: 'not found' }; + const known = UNRAID_HEALTHY.has(d.status) || UNRAID_FAILED.has(d.status); + const kb = Number(d.size); + return { + device_id: name, + device_status: UNRAID_FAILED.has(d.status) ? 2 : 0, + hasSmart: known, + model_name: name, + device_name: name, + temp: Number.isFinite(d.temp) ? d.temp : null, + capacity: kb > 0 ? kb * 1024 : null, + }; + }); + + return { bays: result, href: ctx.serviceLink(config.unraidHref, config.unraidUrl), provider: 'unraid' }; +} diff --git a/ui/widgets/disk-health/i18n/de.json b/ui/widgets/disk-health/i18n/de.json index d9754d66..75b61315 100644 --- a/ui/widgets/disk-health/i18n/de.json +++ b/ui/widgets/disk-health/i18n/de.json @@ -4,6 +4,7 @@ "diskProvider.label": "Quelle", "diskProvider.opt.scrutiny": "Scrutiny", "diskProvider.opt.truenas": "TrueNAS", + "diskProvider.opt.unraid": "Unraid", "scrutinyUrl.label": "Scrutiny-URL", "scrutinyUrl.placeholder": "scrutiny:8080", "scrutinyToken.label": "Scrutiny-API-Token", @@ -17,6 +18,13 @@ "truenasHref.label": "Link-URL", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "Die Adresse, unter der dein Browser diesen Dienst öffnet. Leer lassen, um die Adresse oben zu verwenden, sofern dein Browser sie erreicht.", + "unraidUrl.label": "Unraid-URL", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Unraid-API-Schlüssel", + "unraidApiKey.hint": "Ein Schlüssel mit der Rolle Viewer genügt. Unraid meldet deaktivierte und fehlende Laufwerke, aber keine SMART-Warnungen. Dafür Scrutiny verwenden.", + "unraidHref.label": "Link-URL", + "unraidHref.placeholder": "http://tower.local", + "unraidHref.hint": "Die Adresse, unter der dein Browser diesen Dienst öffnet. Leer lassen, um die Adresse oben zu verwenden, sofern dein Browser sie erreicht.", "bays.label": "Schächte", "bays.rowLabel": "Schacht", "bays.fetchLabel": "Laufwerke abrufen", diff --git a/ui/widgets/disk-health/i18n/en.json b/ui/widgets/disk-health/i18n/en.json index 41d0d8ea..8f9de631 100644 --- a/ui/widgets/disk-health/i18n/en.json +++ b/ui/widgets/disk-health/i18n/en.json @@ -4,6 +4,7 @@ "diskProvider.label": "Source", "diskProvider.opt.scrutiny": "Scrutiny", "diskProvider.opt.truenas": "TrueNAS", + "diskProvider.opt.unraid": "Unraid", "scrutinyUrl.label": "Scrutiny URL", "scrutinyUrl.placeholder": "scrutiny:8080", "scrutinyToken.label": "Scrutiny API Token", @@ -17,6 +18,13 @@ "truenasHref.label": "Link URL", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "The address your browser uses for this service. Leave blank to use the one above, if your browser can reach it.", + "unraidUrl.label": "Unraid URL", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Unraid API Key", + "unraidApiKey.hint": "A key with the Viewer role is enough. Unraid reports disabled and missing drives, not SMART warnings. Use Scrutiny for those.", + "unraidHref.label": "Link URL", + "unraidHref.placeholder": "http://tower.local", + "unraidHref.hint": "The address your browser uses for this service. Leave blank to use the one above, if your browser can reach it.", "bays.label": "Bays", "bays.rowLabel": "Bay", "bays.fetchLabel": "Fetch Drives", diff --git a/ui/widgets/disk-health/i18n/es.json b/ui/widgets/disk-health/i18n/es.json index 1d5ce914..16b53d55 100644 --- a/ui/widgets/disk-health/i18n/es.json +++ b/ui/widgets/disk-health/i18n/es.json @@ -4,6 +4,7 @@ "diskProvider.label": "Origen", "diskProvider.opt.scrutiny": "Scrutiny", "diskProvider.opt.truenas": "TrueNAS", + "diskProvider.opt.unraid": "Unraid", "scrutinyUrl.label": "URL de Scrutiny", "scrutinyUrl.placeholder": "scrutiny:8080", "scrutinyToken.label": "Token de API de Scrutiny", @@ -17,6 +18,13 @@ "truenasHref.label": "URL del enlace", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "La dirección con la que tu navegador abre este servicio. Déjalo en blanco para usar la de arriba, si tu navegador puede acceder a ella.", + "unraidUrl.label": "URL de Unraid", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Clave de API de Unraid", + "unraidApiKey.hint": "Basta con una clave con el rol Viewer. Unraid informa de las unidades desactivadas y ausentes, no de los avisos SMART. Para ellos, usa Scrutiny.", + "unraidHref.label": "URL del enlace", + "unraidHref.placeholder": "http://tower.local", + "unraidHref.hint": "La dirección con la que tu navegador abre este servicio. Déjalo en blanco para usar la de arriba, si tu navegador puede acceder a ella.", "bays.label": "Bahías", "bays.rowLabel": "Bahía", "bays.fetchLabel": "Obtener unidades", diff --git a/ui/widgets/disk-health/i18n/fa.json b/ui/widgets/disk-health/i18n/fa.json index db98acf7..6a8d8e0c 100644 --- a/ui/widgets/disk-health/i18n/fa.json +++ b/ui/widgets/disk-health/i18n/fa.json @@ -4,6 +4,7 @@ "diskProvider.label": "منبع", "diskProvider.opt.scrutiny": "Scrutiny", "diskProvider.opt.truenas": "TrueNAS", + "diskProvider.opt.unraid": "Unraid", "scrutinyUrl.label": "آدرس Scrutiny", "scrutinyUrl.placeholder": "scrutiny:8080", "scrutinyToken.label": "توکن API Scrutiny", @@ -17,6 +18,13 @@ "truenasHref.label": "آدرس پیوند", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "آدرسی که مرورگرتان برای این سرویس استفاده می‌کند. اگر مرورگرتان به آدرس بالا دسترسی دارد، خالی بگذارید.", + "unraidUrl.label": "آدرس Unraid", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "کلید API Unraid", + "unraidApiKey.hint": "کلیدی با نقش Viewer کافی است. Unraid دیسک‌های غیرفعال و گم‌شده را گزارش می‌کند، نه هشدارهای SMART را. برای آن‌ها از Scrutiny استفاده کنید.", + "unraidHref.label": "آدرس پیوند", + "unraidHref.placeholder": "http://tower.local", + "unraidHref.hint": "آدرسی که مرورگرتان برای این سرویس استفاده می‌کند. اگر مرورگرتان به آدرس بالا دسترسی دارد، خالی بگذارید.", "bays.label": "محفظه‌ها", "bays.rowLabel": "محفظه", "bays.fetchLabel": "دریافت دیسک‌ها", diff --git a/ui/widgets/disk-health/i18n/fr.json b/ui/widgets/disk-health/i18n/fr.json index 3b22ee1b..0894ff94 100644 --- a/ui/widgets/disk-health/i18n/fr.json +++ b/ui/widgets/disk-health/i18n/fr.json @@ -4,6 +4,7 @@ "diskProvider.label": "Source", "diskProvider.opt.scrutiny": "Scrutiny", "diskProvider.opt.truenas": "TrueNAS", + "diskProvider.opt.unraid": "Unraid", "scrutinyUrl.label": "URL Scrutiny", "scrutinyUrl.placeholder": "scrutiny:8080", "scrutinyToken.label": "Jeton d’API Scrutiny", @@ -17,6 +18,13 @@ "truenasHref.label": "URL du lien", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "L’adresse que votre navigateur utilise pour ce service. Laissez vide pour utiliser celle ci-dessus, si votre navigateur peut l’atteindre.", + "unraidUrl.label": "URL Unraid", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Clé d’API Unraid", + "unraidApiKey.hint": "Une clé avec le rôle Viewer suffit. Unraid signale les disques désactivés et manquants, pas les alertes SMART. Pour celles-ci, utilisez Scrutiny.", + "unraidHref.label": "URL du lien", + "unraidHref.placeholder": "http://tower.local", + "unraidHref.hint": "L’adresse que votre navigateur utilise pour ce service. Laissez vide pour utiliser celle ci-dessus, si votre navigateur peut l’atteindre.", "bays.label": "Baies", "bays.rowLabel": "Baie", "bays.fetchLabel": "Récupérer les disques", diff --git a/ui/widgets/disk-health/i18n/zh-Hans.json b/ui/widgets/disk-health/i18n/zh-Hans.json index 464c3007..801963e1 100644 --- a/ui/widgets/disk-health/i18n/zh-Hans.json +++ b/ui/widgets/disk-health/i18n/zh-Hans.json @@ -4,6 +4,7 @@ "diskProvider.label": "数据源", "diskProvider.opt.scrutiny": "Scrutiny", "diskProvider.opt.truenas": "TrueNAS", + "diskProvider.opt.unraid": "Unraid", "scrutinyUrl.label": "Scrutiny 地址", "scrutinyUrl.placeholder": "scrutiny:8080", "scrutinyToken.label": "Scrutiny API 令牌", @@ -17,6 +18,13 @@ "truenasHref.label": "链接地址", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "浏览器访问此服务所用的地址。留空则使用上方地址(前提是浏览器能访问它)。", + "unraidUrl.label": "Unraid 地址", + "unraidUrl.placeholder": "tower.local", + "unraidApiKey.label": "Unraid API 密钥", + "unraidApiKey.hint": "具有 Viewer 角色的密钥即可。Unraid 只报告已停用和缺失的硬盘,不报告 SMART 警告。如需 SMART 警告,请使用 Scrutiny。", + "unraidHref.label": "链接地址", + "unraidHref.placeholder": "http://tower.local", + "unraidHref.hint": "浏览器访问此服务所用的地址。留空则使用上方地址(前提是浏览器能访问它)。", "bays.label": "硬盘位", "bays.rowLabel": "硬盘位", "bays.fetchLabel": "获取硬盘", diff --git a/ui/widgets/disk-health/widget.json b/ui/widgets/disk-health/widget.json index f7cd1968..fd43c99c 100644 --- a/ui/widgets/disk-health/widget.json +++ b/ui/widgets/disk-health/widget.json @@ -23,6 +23,10 @@ { "value": "truenas", "label": "diskProvider.opt.truenas" + }, + { + "value": "unraid", + "label": "diskProvider.opt.unraid" } ] }, @@ -92,6 +96,39 @@ }, "cosmetic": true }, + { + "key": "unraidUrl", + "type": "text", + "label": "unraidUrl.label", + "placeholder": "unraidUrl.placeholder", + "showIf": { + "field": "diskProvider", + "equals": "unraid" + } + }, + { + "key": "unraidApiKey", + "type": "secret", + "label": "unraidApiKey.label", + "hint": "unraidApiKey.hint", + "showIf": { + "field": "diskProvider", + "equals": "unraid" + } + }, + { + "key": "unraidHref", + "type": "text", + "label": "unraidHref.label", + "placeholder": "unraidHref.placeholder", + "hint": "unraidHref.hint", + "optional": true, + "showIf": { + "field": "diskProvider", + "equals": "unraid" + }, + "cosmetic": true + }, { "key": "bays", "type": "picklist", From 94a5e63bd42074d75329af95965b45951a396e50 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:41:42 -0400 Subject: [PATCH 2/2] Report a drive missing from the Unraid array as failed Unraid leaves slots without a device out of the array query, so a missing or pulled drive never arrives with its own state. Treat a configured bay absent from the reply as a failed drive. --- api/test/disk-health-data.test.js | 18 +++++++++++------- ui/widgets/disk-health/data.js | 22 +++++++++++++--------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/api/test/disk-health-data.test.js b/api/test/disk-health-data.test.js index a0d205ed..60a8fd6f 100644 --- a/api/test/disk-health-data.test.js +++ b/api/test/disk-health-data.test.js @@ -221,11 +221,9 @@ for (const endpoint of ['', 'devices']) { for (const [status, health] of [ ['DISK_OK', 'healthy'], ['DISK_NEW', 'healthy'], - ['DISK_NP_MISSING', 'failed'], ['DISK_INVALID', 'failed'], ['DISK_WRONG', 'failed'], ['DISK_DSBL', 'failed'], - ['DISK_NP_DSBL', 'failed'], ['DISK_DSBL_NEW', 'failed'], ['SOMETHING_NEW', 'unknown'], [null, 'unknown'], @@ -246,10 +244,13 @@ test('an Unraid drive reports its size in bytes and no temperature while spun do assert.equal(bay.device_name, 'disk1'); }); -test('an Unraid bay whose slot is now unassigned is reported as not found', async () => { - const array = { disks: [{ name: 'disk1', device: '', status: 'DISK_NP' }] }; - const [bay] = (await dataFn(makeCtx('', { ...UNRAID, bays: ['disk1'] }, unraidFetch(array)))).bays; - assert.equal(bay.error, 'not found'); +test('an Unraid bay left out of the reply is a failed drive, as Unraid omits a missing drive', async () => { + const array = { disks: [unraidSlot('disk1', 'DISK_OK')] }; + const [, bay] = (await dataFn(makeCtx('', { ...UNRAID, bays: ['disk1', 'disk2'] }, unraidFetch(array)))).bays; + assert.equal(bay.error, undefined); + assert.equal(bay.hasSmart, true); + assert.equal(bay.device_status, 2); + assert.equal(bay.device_name, 'disk2'); }); test('a key without array access is reported as an auth failure', async () => { @@ -265,7 +266,10 @@ test('the Unraid picker asks for the key before calling the server', async () => const fetchJSON = async () => { called = true; }; - await assert.rejects(dataFn(makeCtx('devices', { ...UNRAID, unraidApiKey: '' }, fetchJSON)), { kind: 'invalid' }); + await assert.rejects(dataFn(makeCtx('devices', { ...UNRAID, unraidApiKey: '' }, fetchJSON)), { + kind: 'invalid', + message: 'Enter the Unraid API key first.', + }); assert.equal(called, false); }); diff --git a/ui/widgets/disk-health/data.js b/ui/widgets/disk-health/data.js index d697c7dc..6c5e913c 100644 --- a/ui/widgets/disk-health/data.js +++ b/ui/widgets/disk-health/data.js @@ -218,14 +218,7 @@ async function unraidDiskOptions(ctx) { } const UNRAID_HEALTHY = new Set(['DISK_OK', 'DISK_NEW']); -const UNRAID_FAILED = new Set([ - 'DISK_NP_MISSING', - 'DISK_INVALID', - 'DISK_WRONG', - 'DISK_DSBL', - 'DISK_NP_DSBL', - 'DISK_DSBL_NEW', -]); +const UNRAID_FAILED = new Set(['DISK_INVALID', 'DISK_WRONG', 'DISK_DSBL', 'DISK_DSBL_NEW']); async function diskHealthUnraid(ctx) { const { config } = ctx; @@ -238,7 +231,18 @@ async function diskHealthUnraid(ctx) { const result = bays.map(name => { if (!name) return null; const d = byName[name]; - if (!d) return { device_id: name, device_status: 0, hasSmart: false, error: 'not found' }; + /* Unraid leaves a slot with no drive out of the reply, so a missing + drive is an absent bay. Reading it as not found hides a degraded array. */ + if (!d) + return { + device_id: name, + device_status: 2, + hasSmart: true, + model_name: name, + device_name: name, + temp: null, + capacity: null, + }; const known = UNRAID_HEALTHY.has(d.status) || UNRAID_FAILED.has(d.status); const kb = Number(d.size); return {