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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions api/test/system-summary-unraid-data.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
124 changes: 123 additions & 1 deletion ui/widgets/system-summary/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function systemSummary(ctx) {
system: systemSummaryLocal,
glances: systemSummaryGlances,
beszel: systemSummaryBeszel,
unraid: systemSummaryUnraid,
},
{ field: 'statProvider', default: 'system' },
);
Expand All @@ -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' },
);
}
Expand Down Expand Up @@ -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,
};
}
10 changes: 10 additions & 0 deletions ui/widgets/system-summary/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading