Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions api/test/disk-health-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,107 @@ 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_INVALID', 'failed'],
['DISK_WRONG', 'failed'],
['DISK_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 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 () => {
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',
message: 'Enter the Unraid API key first.',
});
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');
});
92 changes: 92 additions & 0 deletions ui/widgets/disk-health/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function diskDevices(ctx) {
{
scrutiny: scrutinyDeviceOptions,
truenas: truenasPoolOptions,
unraid: unraidDiskOptions,
},
{ field: 'diskProvider', default: 'scrutiny' },
);
Expand Down Expand Up @@ -85,6 +86,7 @@ function diskHealth(ctx) {
{
scrutiny: diskHealthScrutiny,
truenas: diskHealthTrueNas,
unraid: diskHealthUnraid,
},
{ field: 'diskProvider', default: 'scrutiny' },
);
Expand Down Expand Up @@ -166,3 +168,93 @@ 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_INVALID', 'DISK_WRONG', 'DISK_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];
/* 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 {
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' };
}
8 changes: 8 additions & 0 deletions ui/widgets/disk-health/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions ui/widgets/disk-health/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions ui/widgets/disk-health/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions ui/widgets/disk-health/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": "دریافت دیسک‌ها",
Expand Down
8 changes: 8 additions & 0 deletions ui/widgets/disk-health/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions ui/widgets/disk-health/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 令牌",
Expand All @@ -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": "获取硬盘",
Expand Down
Loading
Loading