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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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.
- System Summary can read its figures from a host running Glances or Beszel.

### 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
- **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
- **Disk health**: TrueNAS, Scrutiny
- **Backup**: Duplicati, Kopia
- **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami
Expand Down
176 changes: 176 additions & 0 deletions api/test/system-summary-beszel-data.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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 SYSTEM = { id: 'sys1', name: 'homelab', status: 'up' };
const STATS = {
cpu: 31.58,
mp: 43.77,
d: 465.88,
du: 120.31,
dp: 26.98,
t: { coretemp_package_id_0: 69, iwlwifi_1: 48 },
cpub: [20.36, 9.3, 45.52, 0, 22.9],
efs: { media: { d: 2000, du: 500 } },
};
const INFO = { info: { u: 267222, cpu: 31.58 } };

/* Stands in for a PocketBase hub: a login hands out a token, and a request
carrying anything else is answered the way PocketBase answers an
unauthenticated read, with an empty list rather than a refusal. */
function hub({ collection = '_superusers', password = 'pw', tokens = ['t1', 't2'] } = {}) {
const state = { issued: 0, logins: [], reads: [] };
const live = new Set();
return {
state,
expire: () => live.clear(),
fetchJSON: async (url, opts = {}) => {
const login = /\/api\/collections\/(\w+)\/auth-with-password$/.exec(url);
if (login) {
state.logins.push(login[1]);
const body = JSON.parse(opts.body || '{}');
if (login[1] !== collection || body.password !== password) return { status: 400, data: { message: 'Failed' } };
const token = tokens[Math.min(state.issued++, tokens.length - 1)];
live.add(token);
return { status: 200, data: { token } };
}
state.reads.push(url);
const authed = live.has(opts.headers?.Authorization);
if (url.includes('/systems/records/')) {
return authed ? { status: 200, data: INFO } : { status: 404, data: null };
}
if (url.includes('/systems/records')) {
return { status: 200, data: { items: authed ? [SYSTEM] : [] } };
}
if (url.includes('/system_stats/records')) {
return { status: 200, data: { items: authed ? [{ stats: STATS }] : [] } };
}
return { status: 404, data: null };
},
};
}

function ctxFor(config, h = hub(), endpoint = 'system') {
const ctx = {
endpoint,
config: {
statProvider: 'beszel',
beszelUrl: 'http://beszel:8090',
beszelUser: 'me@x',
beszelPass: 'pw',
...config,
},
normalizeBase,
fetchJSON: h.fetchJSON,
...errorParts(),
};
ctx.dispatchProvider = (handlers, opts) => dispatchProvider(ctx, handlers, opts);
return ctx;
}

const SLOTS = [
{ type: 'cpu' },
{ type: 'temp', sensor: 'coretemp_package_id_0' },
{ type: 'disk', primary: '/', secondary: 'media' },
];

test('a system maps onto the shape the widget already renders', async () => {
const r = await dataFn(ctxFor({ beszelSystem: 'sys1', slots: SLOTS }));
assert.equal(r.cpu, 31.58);
assert.equal(r.ram, 43.77);
assert.equal(r.iowait, 45.52);
assert.equal(r.uptime, 267222);
assert.equal(r.procs, null, 'Beszel reports no process count');
assert.deepEqual(r.temps, { coretemp_package_id_0: 69, iwlwifi_1: 48 });
assert.deepEqual(r.disks, [
{ mount: '/', usedPct: 26.98, totalGb: 465.88 },
{ mount: 'media', usedPct: 25, totalGb: 2000 },
]);
});

test('a disk slot left blank reads the root filesystem', async () => {
const r = await dataFn(ctxFor({ beszelSystem: 'sys1', slots: [{ type: 'disk' }] }));
assert.deepEqual(r.disks, [{ mount: '/', usedPct: 26.98, totalGb: 465.88 }]);
});

test('a filesystem the hub does not report reads as empty', async () => {
const r = await dataFn(ctxFor({ beszelSystem: 'sys1', slots: [{ type: 'disk', primary: 'nope' }] }));
assert.deepEqual(r.disks, [{ mount: 'nope', usedPct: 0, totalGb: 0 }]);
});

test('the account collection that answers is the one reused afterwards', async () => {
const h = hub({ collection: 'users' });
await dataFn(ctxFor({ beszelUrl: 'http://legacy:8090', beszelSystem: 'sys1', slots: SLOTS }, h));
assert.deepEqual(h.state.logins, ['_superusers', 'users'], 'both are tried, in order');
const before = h.state.logins.length;
await dataFn(ctxFor({ beszelUrl: 'http://legacy:8090', beszelSystem: 'sys1', slots: SLOTS }, h));
assert.equal(h.state.logins.length, before, 'a second poll reuses the session');
});

/* The failure this widget would otherwise get wrong: PocketBase answers a stale
token with an empty list, which reads exactly like a hub with no systems. */
test('an expired session is renewed rather than reported as an empty hub', async () => {
const h = hub({ tokens: ['first', 'second'] });
const config = { beszelUrl: 'http://renew:8090', beszelSystem: 'sys1', slots: SLOTS };
await dataFn(ctxFor(config, h));
h.expire();
const r = await dataFn(ctxFor(config, h));
assert.equal(r.cpu, 31.58, 'the retry with a fresh token returns real figures');
assert.equal(h.state.issued, 2, 'exactly one extra login');
});

test('a hub that really has no systems says so instead of showing nothing', async () => {
const h = hub();
h.fetchJSON = async url =>
url.includes('auth-with-password') ? { status: 200, data: { token: 't' } } : { status: 200, data: { items: [] } };
await assert.rejects(dataFn(ctxFor({ beszelUrl: 'http://bare:8090', beszelSystem: 'sys1', slots: SLOTS }, h)), {
message: 'Beszel is reporting no systems for this account',
});
});

test('a rejected password is an auth failure, not a missing system', async () => {
const h = hub({ password: 'other' });
await assert.rejects(
dataFn(ctxFor({ beszelUrl: 'http://wrongpw:8090', beszelSystem: 'sys1', slots: SLOTS }, h)),
e => {
assert.equal(e.kind, 'auth');
return true;
},
);
});

test('a system that has gone away asks for a new choice', async () => {
const r = dataFn(ctxFor({ beszelUrl: 'http://gone:8090', beszelSystem: 'removed', slots: SLOTS }));
await assert.rejects(r, e => {
assert.equal(e.kind, 'invalid');
assert.match(e.message, /Choose it again/);
return true;
});
});

test('the connection details are required before anything is fetched', async () => {
for (const config of [{ beszelUrl: '' }, { beszelUser: '', beszelSystem: 'sys1' }, { beszelSystem: '' }]) {
await assert.rejects(dataFn(ctxFor({ slots: SLOTS, beszelSystem: 'sys1', ...config })), e => {
assert.equal(e.kind, 'invalid');
return true;
});
}
});

test('the pickers offer the hub systems and that system sensors', async () => {
const systems = await dataFn(ctxFor({ beszelUrl: 'http://pick:8090', slots: [] }, hub(), 'systems'));
assert.deepEqual(systems.options, [{ value: 'sys1', label: 'homelab' }]);

const sensors = await dataFn(
ctxFor({ beszelUrl: 'http://pick:8090', beszelSystem: 'sys1', slots: [] }, hub(), 'sensors'),
);
assert.deepEqual(sensors.options, [
{ value: 'coretemp_package_id_0', label: 'coretemp_package_id_0' },
{ value: 'iwlwifi_1', label: 'iwlwifi_1' },
]);
});
158 changes: 157 additions & 1 deletion ui/widgets/system-summary/data.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module.exports = async function (ctx) {
if (ctx.endpoint === 'speed') return speed(ctx);
if (ctx.endpoint === 'sensors') return glancesSensorOptions(ctx);
if (ctx.endpoint === 'sensors') return sensorOptions(ctx);
if (ctx.endpoint === 'systems') return beszelSystemOptions(ctx);
return systemSummary(ctx);
};

Expand All @@ -9,11 +10,21 @@ function systemSummary(ctx) {
{
system: systemSummaryLocal,
glances: systemSummaryGlances,
beszel: systemSummaryBeszel,
},
{ field: 'statProvider', default: 'system' },
);
}

/* Only a source that names its sensors offers the picker, so the local machine
has no handler here. */
function sensorOptions(ctx) {
return ctx.dispatchProvider(
{ glances: glancesSensorOptions, beszel: beszelSensorOptions },
{ field: 'statProvider' },
);
}

/* Mount paths come from the widget's disk slots, then the global
stats.diskMount setting, then '/'. */
async function systemSummaryLocal({ config, settings, metrics }) {
Expand Down Expand Up @@ -188,3 +199,148 @@ async function systemSummaryGlances(ctx) {
uptime,
};
}

/* Beszel is a PocketBase app: a login returns a token rather than accepting a
key. The account collection moved between releases, so both are tried and the
working one is remembered with its token. */
const BESZEL_COLLECTIONS = ['_superusers', 'users'];
const _beszelSession = new Map();

async function beszelLogin(ctx, base) {
const { config, fetchJSON } = ctx;
if (!config.beszelUser || !config.beszelPass)
ctx.fail('Enter the Beszel account and password first.', { kind: ctx.KIND.INVALID });
const body = JSON.stringify({ identity: config.beszelUser, password: config.beszelPass });
const known = _beszelSession.get(base);
const tries = known ? [known.collection] : BESZEL_COLLECTIONS;

for (const collection of tries) {
const r = await fetchJSON(`${base}/api/collections/${collection}/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
timeout: 8000,
});
if (r.status < 400 && r.data?.token) {
const session = { collection, token: r.data.token };
_beszelSession.set(base, session);
return session;
}
}
_beszelSession.delete(base);
ctx.fail('Beszel rejected the account and password', { kind: ctx.KIND.AUTH });
}

/* An expired token is not refused: PocketBase applies the collection's read
rule instead, and an unauthenticated read of systems is an empty list. So an
empty answer is retried once with a fresh token before it is believed. */
async function beszelGet(ctx, base, path) {
const { fetchJSON } = ctx;
let session = _beszelSession.get(base) || (await beszelLogin(ctx, base));
for (let attempt = 0; attempt < 2; attempt++) {
const r = await fetchJSON(`${base}${path}`, {
headers: { Authorization: session.token },
timeout: 8000,
});
if (r.status === 401 || r.status === 403 || (Array.isArray(r.data?.items) && !r.data.items.length)) {
if (attempt === 0) {
_beszelSession.delete(base);
session = await beszelLogin(ctx, base);
continue;
}
return r.data;
}
if (r.status >= 400) ctx.fail('Beszel HTTP ' + r.status);
return r.data;
}
}

function beszelBase(ctx) {
if (!ctx.config.beszelUrl) ctx.fail('Enter the Beszel URL first.', { kind: ctx.KIND.INVALID });
return ctx.normalizeBase(ctx.config.beszelUrl);
}

async function beszelSystems(ctx) {
const base = beszelBase(ctx);
const data = await beszelGet(ctx, base, '/api/collections/systems/records?perPage=200&fields=id,name,status');
return Array.isArray(data?.items) ? data.items : [];
}

async function beszelSystemOptions(ctx) {
const options = (await beszelSystems(ctx)).map(s => ({ value: s.id, label: s.name || s.id }));
return { options };
}

async function beszelLatest(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;
}

async function beszelSensorOptions(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 temps = stats && typeof stats.t === 'object' ? stats.t : {};
return { options: Object.keys(temps).map(label => ({ value: label, label })) };
}

/* The root filesystem is the one Beszel reports directly. Every other one is
named, and a slot names it the way Beszel does. */
function beszelDisk(stats, name) {
if (!name || name === '/') {
return { usedPct: Number(stats?.dp) || 0, totalGb: Number(stats?.d) || 0 };
}
const fs = stats?.efs?.[name];
const total = Number(fs?.d) || 0;
const used = Number(fs?.du) || 0;
return { usedPct: total > 0 ? (used / total) * 100 : 0, totalGb: total };
}

async function systemSummaryBeszel(ctx) {
const base = beszelBase(ctx);
const id = ctx.config.beszelSystem;
if (!id) ctx.fail('Choose a system first.', { kind: ctx.KIND.INVALID });

const systems = await beszelSystems(ctx);
if (!systems.length) ctx.fail('Beszel is reporting no systems for this account');
const system = systems.find(s => s.id === id);
if (!system) ctx.fail('That system is no longer in Beszel. Choose it again.', { kind: ctx.KIND.INVALID });

const [record, stats] = await Promise.all([
beszelGet(ctx, base, `/api/collections/systems/records/${encodeURIComponent(id)}?fields=info`),
beszelLatest(ctx, base, id),
]);
if (!stats) ctx.fail('Beszel has no readings for that system yet');

const slots = ctx.config.slots || [];
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);
}
const disks = [...mounts].map(mount => ({ mount, ...beszelDisk(stats, mount) }));

const temps = {};
for (const [label, value] of Object.entries(stats.t || {})) {
if (typeof value === 'number') temps[label] = value;
}
const iowait = Array.isArray(stats.cpub) && typeof stats.cpub[2] === 'number' ? stats.cpub[2] : null;

return {
cpu: typeof stats.cpu === 'number' ? stats.cpu : null,
ram: typeof stats.mp === 'number' ? stats.mp : null,
temp: Object.values(temps)[0] ?? null,
temps,
disks,
iowait,
procs: null,
uptime: Number(record?.info?.u) || null,
};
}
Loading