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,8 +9,9 @@

### Added

- System Summary can read its figures from a host running Glances or Beszel, or

Check warning on line 12 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / test

entry is 178 characters; keep it to one line: "System Summary can read its figures from a host running G..."

Check warning on line 12 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / test

writes to the reader; state the fact about the software: "System Summary can read its figures from a host running G..."
from the Unraid API.
from the Unraid API. The network row's throughput follows the same source, on
an interface you pick.

### Changed

Expand Down
186 changes: 186 additions & 0 deletions api/test/system-summary-throughput-data.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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'));

function ctxFor(config, reply, endpoint = 'throughput') {
const ctx = {
endpoint,
config,
normalizeBase,
fetchJSON: async (url, opts) => reply(url, opts),
...errorParts(),
};
ctx.dispatchProvider = (handlers, opts) => dispatchProvider(ctx, handlers, opts);
return ctx;
}

/* ── Glances: the source reports a rate ──────────────────────────────────── */

const GLANCES_NET = [
{ interface_name: 'lo', bytes_recv_rate_per_sec: 0, bytes_sent_rate_per_sec: 0 },
{ interface_name: 'eth0', bytes_recv_rate_per_sec: 2048, bytes_sent_rate_per_sec: 56 },
];
const glancesReply = url => ({ status: 200, data: url.endsWith('/network') ? GLANCES_NET : '1:00:00' });

test('a Glances rate is taken as given', async () => {
const ctx = ctxFor(
{ statProvider: 'glances', glancesUrl: 'http://g:61208', network: { interface: 'eth0' } },
glancesReply,
);
assert.deepEqual(await dataFn(ctx), { rx: 2048, tx: 56 });
});

/* Version 3 has no rate field, only the window and how long it covered. */
test('an older Glances is divided by its own window', async () => {
const v3 = [{ interface_name: 'eth0', bytes_recv: 6000, bytes_sent: 3000, time_since_update: 3 }];
const ctx = ctxFor(
{ statProvider: 'glances', glancesUrl: 'http://old:61208', network: { interface: 'eth0' } },
url => ({ status: 200, data: url.endsWith('/network') ? v3 : '1:00:00' }),
);
assert.deepEqual(await dataFn(ctx), { rx: 2000, tx: 1000 });
});

test('the Glances interface list is offered by name', async () => {
const ctx = ctxFor(
{ statProvider: 'glances', glancesUrl: 'http://g2:61208', network: {} },
glancesReply,
'interfaces',
);
const r = await dataFn(ctx);
assert.deepEqual(r.options, [
{ value: 'lo', label: 'lo' },
{ value: 'eth0', label: 'eth0' },
]);
});

/* ── Beszel: a rate for the host, totals per interface ───────────────────── */

const beszelReply =
(stats, created = '2026-08-18T12:00:00.000Z') =>
(url, opts) => {
if (url.includes('auth-with-password')) return { status: 200, data: { token: 'tok' } };
if (url.includes('/systems/records')) return { status: 200, data: { items: [{ id: 'sys1', name: 'host' }] } };
return { status: 200, data: { items: [{ stats, created }] } };
};
const beszelConfig = extra => ({
statProvider: 'beszel',
beszelUrl: 'http://b:8090',
beszelUser: 'u',
beszelPass: 'p',
beszelSystem: 'sys1',
...extra,
});

test('the whole host uses the rate Beszel already computed', async () => {
const ctx = ctxFor(
beszelConfig({ network: { interface: '*' } }),
beszelReply({ b: [500, 9000], ni: { eth0: [1, 2, 3, 4] } }),
);
assert.deepEqual(await dataFn(ctx), { rx: 9000, tx: 500 }, 'b is [sent, recv]');
});

test('one interface is derived from its totals between two records', async () => {
const config = beszelConfig({ beszelUrl: 'http://rate:8090', network: { interface: 'eth0' } });
const at = t => new Date(Date.parse('2026-08-18T12:00:00.000Z') + t * 1000).toISOString();

const first = ctxFor(config, beszelReply({ ni: { eth0: [0, 0, 1000, 2000] } }, at(0)));
assert.equal(await dataFn(first), null, 'one record is not a rate');

const second = ctxFor(config, beszelReply({ ni: { eth0: [0, 0, 7000, 14000] } }, at(60)));
assert.deepEqual(await dataFn(second), { rx: 200, tx: 100 }, 'divided by the minute between records');
});

/* Beszel writes a record once a minute. Polling more often than that must not
read the same record as a minute of no traffic. */
test('polling between records repeats the rate rather than reporting nothing', async () => {
const config = beszelConfig({ beszelUrl: 'http://repeat:8090', network: { interface: 'eth0' } });
const at = t => new Date(Date.parse('2026-08-18T13:00:00.000Z') + t * 1000).toISOString();
await dataFn(ctxFor(config, beszelReply({ ni: { eth0: [0, 0, 0, 0] } }, at(0))));
const moved = await dataFn(ctxFor(config, beszelReply({ ni: { eth0: [0, 0, 600, 1200] } }, at(60))));
assert.deepEqual(moved, { rx: 20, tx: 10 });

const again = await dataFn(ctxFor(config, beszelReply({ ni: { eth0: [0, 0, 600, 1200] } }, at(60))));
assert.deepEqual(again, moved, 'the same record gives the same answer, not zero');
});

test('a counter that went backwards is skipped rather than reported negative', async () => {
const config = beszelConfig({ beszelUrl: 'http://reset:8090', network: { interface: 'eth0' } });
const at = t => new Date(Date.parse('2026-08-18T14:00:00.000Z') + t * 1000).toISOString();
await dataFn(ctxFor(config, beszelReply({ ni: { eth0: [0, 0, 9000, 9000] } }, at(0))));
const after = await dataFn(ctxFor(config, beszelReply({ ni: { eth0: [0, 0, 5, 5] } }, at(60))));
assert.equal(after, null);
});

test('the Beszel list offers the whole host alongside each interface', async () => {
const ctx = ctxFor(
beszelConfig({ beszelUrl: 'http://list:8090', network: {} }),
beszelReply({ ni: { eth0: [0, 0, 0, 0], wlan0: [0, 0, 0, 0] } }),
'interfaces',
);
const r = await dataFn(ctx);
assert.deepEqual(
r.options.map(o => o.value),
['*', 'eth0', 'wlan0'],
);
});

/* ── Unraid: totals only ─────────────────────────────────────────────────── */

const unraidReply = network => () => ({ status: 200, data: { data: { metrics: { network } } } });
const unraidConfig = extra => ({ statProvider: 'unraid', unraidUrl: 'tower', unraidApiKey: 'K', ...extra });

test('Unraid counters become a rate across two reads', async () => {
const config = unraidConfig({ unraidUrl: 'u1', network: { interface: 'eth0' } });
const first = await dataFn(ctxFor(config, unraidReply([{ name: 'eth0', bytesReceived: '100', bytesSent: '50' }])));
assert.equal(first, null);

await new Promise(r => setTimeout(r, 20));
const second = await dataFn(ctxFor(config, unraidReply([{ name: 'eth0', bytesReceived: '1100', bytesSent: '550' }])));
assert.ok(second.rx > 0 && second.tx > 0);
assert.ok(second.rx > second.tx);
});

test('the Unraid interface list is offered by name', async () => {
const ctx = ctxFor(unraidConfig({ network: {} }), unraidReply([{ name: 'br0' }, { name: 'eth0' }]), 'interfaces');
const r = await dataFn(ctx);
assert.deepEqual(r.options, [
{ value: 'br0', label: 'br0' },
{ value: 'eth0', label: 'eth0' },
]);
});

/* ── shared ──────────────────────────────────────────────────────────────── */

test('an interface the source does not report is named, not left blank', async () => {
const ctx = ctxFor(
{ statProvider: 'glances', glancesUrl: 'http://g3:61208', network: { interface: 'ghost' } },
glancesReply,
);
await assert.rejects(dataFn(ctx), e => {
assert.equal(e.kind, 'invalid');
assert.match(e.message, /ghost/);
return true;
});
});

test('no interface chosen is a configuration problem, not an empty reading', async () => {
for (const config of [
{ statProvider: 'glances', glancesUrl: 'http://g4:61208', network: {} },
{ statProvider: 'unraid', unraidUrl: 'u2', unraidApiKey: 'K', network: {} },
]) {
await assert.rejects(dataFn(ctxFor(config, glancesReply)), e => {
assert.equal(e.kind, 'invalid');
return true;
});
}
});

test('this machine has no throughput handler, since it never reaches the data function', async () => {
const ctx = ctxFor({ statProvider: 'system', network: { interface: 'eth0' } }, glancesReply);
await assert.rejects(dataFn(ctx), /Unknown statProvider/);
});
146 changes: 144 additions & 2 deletions ui/widgets/system-summary/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module.exports = async function (ctx) {
if (ctx.endpoint === 'speed') return speed(ctx);
if (ctx.endpoint === 'sensors') return sensorOptions(ctx);
if (ctx.endpoint === 'systems') return beszelSystemOptions(ctx);
if (ctx.endpoint === 'interfaces') return interfaceOptions(ctx);
if (ctx.endpoint === 'throughput') return throughput(ctx);
return systemSummary(ctx);
};

Expand Down Expand Up @@ -272,14 +274,18 @@ async function beszelSystemOptions(ctx) {
return { options };
}

async function beszelLatest(ctx, base, systemId) {
async function beszelLatestRecord(ctx, base, systemId) {
const filter = encodeURIComponent(`system='${systemId}' && type='1m'`);
const data = await beszelGet(
ctx,
base,
`/api/collections/system_stats/records?filter=(${filter})&sort=-created&perPage=1`,
);
return data?.items?.[0]?.stats || null;
return data?.items?.[0] || null;
}

async function beszelLatest(ctx, base, systemId) {
return (await beszelLatestRecord(ctx, base, systemId))?.stats || null;
}

async function beszelSensorOptions(ctx) {
Expand Down Expand Up @@ -466,3 +472,139 @@ async function systemSummaryUnraid(ctx) {
uptime,
};
}

/* A counter is not a rate. Sources that report totals are turned into one here,
against the previous reading for the same interface. The first reading has
nothing to compare with and reports nothing.

`at` is when the counters were taken, not when they were read. A source that
publishes on its own schedule hands back the same totals to every poll in
between, and dividing those by the time since the last poll reports zero
traffic. Reading it twice is then the same reading, so the rate last worked
out stands until the counters actually move. */
const _netPrev = new Map();

function rateFromTotals(key, at, rx, tx) {
const prev = _netPrev.get(key);
if (prev && at <= prev.at) return prev.rate;
let rate = null;
/* Counters that went backwards mean the interface was reset or replaced.
Skip the window rather than report a negative rate. */
if (prev && rx >= prev.rx && tx >= prev.tx) {
const seconds = (at - prev.at) / 1000;
if (seconds > 0) {
rate = { rx: Math.round((rx - prev.rx) / seconds), tx: Math.round((tx - prev.tx) / seconds) };
}
}
_netPrev.set(key, { at, rx, tx, rate });
return rate;
}

function throughput(ctx) {
return ctx.dispatchProvider(
{
glances: glancesThroughput,
beszel: beszelThroughput,
unraid: unraidThroughput,
},
{ field: 'statProvider' },
);
}

function interfaceOptions(ctx) {
return ctx.dispatchProvider(
{
glances: glancesInterfaces,
beszel: beszelInterfaces,
unraid: unraidInterfaces,
},
{ field: 'statProvider' },
);
}

const chosenInterface = ctx => ctx.config.network?.interface || '';

/* ── Glances ─────────────────────────────────────────────────────────────── */

async function glancesInterfaces(ctx) {
const list = await glancesGet(ctx, 'network');
const options = (Array.isArray(list) ? list : [])
.filter(e => e?.interface_name)
.map(e => ({ value: e.interface_name, label: e.alias || e.interface_name }));
return { options };
}

async function glancesThroughput(ctx) {
const name = chosenInterface(ctx);
if (!name) ctx.fail('Choose a network interface first.', { kind: ctx.KIND.INVALID });
const list = await glancesGet(ctx, 'network');
const e = (Array.isArray(list) ? list : []).find(x => x?.interface_name === name);
if (!e) ctx.fail(`Glances is not reporting the ${name} interface.`, { kind: ctx.KIND.INVALID });
/* Version 4 reports the rate. Version 3 reports the window and its length. */
const perSec = (rate, bytes) => {
if (typeof rate === 'number') return Math.round(rate);
const window = Number(e.time_since_update);
return window > 0 ? Math.round((Number(bytes) || 0) / window) : 0;
};
return {
rx: perSec(e.bytes_recv_rate_per_sec, e.bytes_recv),
tx: perSec(e.bytes_sent_rate_per_sec, e.bytes_sent),
};
}

/* ── Beszel ──────────────────────────────────────────────────────────────── */

async function beszelInterfaces(ctx) {
const base = beszelBase(ctx);
const id = ctx.config.beszelSystem;
if (!id) ctx.fail('Choose a system first.', { kind: ctx.KIND.INVALID });
const stats = await beszelLatest(ctx, base, id);
const options = Object.keys(stats?.ni || {}).map(name => ({ value: name, label: name }));
return { options: [{ value: BESZEL_ALL, label: 'All interfaces' }, ...options] };
}

const BESZEL_ALL = '*';

async function beszelThroughput(ctx) {
const base = beszelBase(ctx);
const id = ctx.config.beszelSystem;
if (!id) ctx.fail('Choose a system first.', { kind: ctx.KIND.INVALID });
const name = chosenInterface(ctx);
if (!name) ctx.fail('Choose a network interface first.', { kind: ctx.KIND.INVALID });
const record = await beszelLatestRecord(ctx, base, id);
const stats = record?.stats;
if (!stats) ctx.fail('Beszel has no readings for that system yet');

/* Beszel already divides the whole host's traffic by its own interval. */
if (name === BESZEL_ALL) {
const b = Array.isArray(stats.b) ? stats.b : [];
return { rx: Math.round(Number(b[1]) || 0), tx: Math.round(Number(b[0]) || 0) };
}
const row = stats.ni?.[name];
if (!Array.isArray(row)) ctx.fail(`Beszel is not reporting the ${name} interface.`, { kind: ctx.KIND.INVALID });
/* Per-interface entries carry totals, and a new record only lands once a
minute, so the record's own timestamp is the window. */
const at = Date.parse(record?.created) || 0;
return rateFromTotals(`beszel|${base}|${id}|${name}`, at, Number(row[3]) || 0, Number(row[2]) || 0);
}

/* ── Unraid ──────────────────────────────────────────────────────────────── */

const UNRAID_NET = 'metrics { network { name bytesReceived bytesSent } }';

async function unraidInterfaces(ctx) {
const data = await unraidQuery(ctx, `{ ${UNRAID_NET} }`);
const options = (data.metrics?.network || []).filter(n => n?.name).map(n => ({ value: n.name, label: n.name }));
return { options };
}

async function unraidThroughput(ctx) {
const name = chosenInterface(ctx);
if (!name) ctx.fail('Choose a network interface first.', { kind: ctx.KIND.INVALID });
const data = await unraidQuery(ctx, `{ ${UNRAID_NET} }`);
const n = (data.metrics?.network || []).find(x => x?.name === name);
if (!n) ctx.fail(`Unraid is not reporting the ${name} interface.`, { kind: ctx.KIND.INVALID });
const base = ctx.normalizeBase(ctx.config.unraidUrl);
/* Unraid's counters move continuously, so the clock is the only window. */
return rateFromTotals(`unraid|${base}|${name}`, Date.now(), Number(n.bytesReceived) || 0, Number(n.bytesSent) || 0);
}
2 changes: 2 additions & 0 deletions ui/widgets/system-summary/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
"network.mode.opt.speed": "Geschwindigkeit",
"network.mode.opt.throughput": "Durchsatz",
"network.mode.opt.uptime": "Betriebszeit",
"network.interface.label": "Schnittstelle",
"network.interface.hint": "Verbindung oben ausfüllen, dann die Liste laden.",
"network.provider.label": "Anbieter",
"network.provider.opt.myspeed": "MySpeed",
"network.provider.opt.speedtest-tracker": "Speedtest Tracker",
Expand Down
2 changes: 2 additions & 0 deletions ui/widgets/system-summary/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
"network.mode.opt.speed": "Speed",
"network.mode.opt.throughput": "Throughput",
"network.mode.opt.uptime": "Uptime",
"network.interface.label": "Interface",
"network.interface.hint": "Fill in the connection above, then load the list.",
"network.provider.label": "Provider",
"network.provider.opt.myspeed": "MySpeed",
"network.provider.opt.speedtest-tracker": "Speedtest Tracker",
Expand Down
2 changes: 2 additions & 0 deletions ui/widgets/system-summary/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
"network.mode.opt.speed": "Velocidad",
"network.mode.opt.throughput": "Rendimiento",
"network.mode.opt.uptime": "Tiempo activo",
"network.interface.label": "Interfaz",
"network.interface.hint": "Completa la conexión de arriba y carga la lista.",
"network.provider.label": "Proveedor",
"network.provider.opt.myspeed": "MySpeed",
"network.provider.opt.speedtest-tracker": "Speedtest Tracker",
Expand Down
Loading