From 50049a682335eb715cbcdade7a5c311bbdda3538 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:16:39 -0400 Subject: [PATCH] Feature: read the throughput row from the chosen source The network row's throughput mode always read the dashboard host, so a tile reporting a remote machine paired that machine's CPU with this one's traffic. Throughput now follows the source, on an interface chosen from a list the source supplies. Only this machine keeps the existing cached local reading. Sources report traffic three different ways. Glances gives a rate, older versions give a window and its length. Beszel gives the whole host as a rate and each interface as totals. Unraid gives totals alone. Totals become a rate against the previous reading, keyed to when the counters were taken rather than when they were read: Beszel publishes once a minute, so dividing an unchanged record by the time since the last poll reports no traffic at all. Remote sources are read every ten seconds rather than every three, since each tick is a call to someone else's server. --- CHANGELOG.md | 3 +- .../system-summary-throughput-data.test.js | 186 +++++++++++ ui/widgets/system-summary/data.js | 146 ++++++++- ui/widgets/system-summary/i18n/de.json | 2 + ui/widgets/system-summary/i18n/en.json | 2 + ui/widgets/system-summary/i18n/es.json | 2 + ui/widgets/system-summary/i18n/fa.json | 2 + ui/widgets/system-summary/i18n/fr.json | 2 + ui/widgets/system-summary/i18n/zh-Hans.json | 2 + ui/widgets/system-summary/index.html | 15 +- ui/widgets/system-summary/widget.json | 288 +++++++++++++++++- 11 files changed, 643 insertions(+), 7 deletions(-) create mode 100644 api/test/system-summary-throughput-data.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 324c444c..61b670c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,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, or - from the Unraid API. + from the Unraid API. The network row's throughput follows the same source, on + an interface you pick. ### Changed diff --git a/api/test/system-summary-throughput-data.test.js b/api/test/system-summary-throughput-data.test.js new file mode 100644 index 00000000..4e252f29 --- /dev/null +++ b/api/test/system-summary-throughput-data.test.js @@ -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/); +}); diff --git a/ui/widgets/system-summary/data.js b/ui/widgets/system-summary/data.js index 3b90dc9f..bd3b12e2 100644 --- a/ui/widgets/system-summary/data.js +++ b/ui/widgets/system-summary/data.js @@ -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); }; @@ -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) { @@ -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); +} diff --git a/ui/widgets/system-summary/i18n/de.json b/ui/widgets/system-summary/i18n/de.json index c890cf0d..a3a8e6ed 100644 --- a/ui/widgets/system-summary/i18n/de.json +++ b/ui/widgets/system-summary/i18n/de.json @@ -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", diff --git a/ui/widgets/system-summary/i18n/en.json b/ui/widgets/system-summary/i18n/en.json index bf3cfbc1..6361f678 100644 --- a/ui/widgets/system-summary/i18n/en.json +++ b/ui/widgets/system-summary/i18n/en.json @@ -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", diff --git a/ui/widgets/system-summary/i18n/es.json b/ui/widgets/system-summary/i18n/es.json index c9226b5e..4d4601b7 100644 --- a/ui/widgets/system-summary/i18n/es.json +++ b/ui/widgets/system-summary/i18n/es.json @@ -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", diff --git a/ui/widgets/system-summary/i18n/fa.json b/ui/widgets/system-summary/i18n/fa.json index 67a27eaa..8c53f6d3 100644 --- a/ui/widgets/system-summary/i18n/fa.json +++ b/ui/widgets/system-summary/i18n/fa.json @@ -52,6 +52,8 @@ "network.mode.opt.speed": "سرعت", "network.mode.opt.throughput": "گذردهی", "network.mode.opt.uptime": "زمان کارکرد", + "network.interface.label": "رابط شبکه", + "network.interface.hint": "اتصال بالا را کامل کنید، سپس فهرست را بارگیری کنید.", "network.provider.label": "ارائه‌دهنده", "network.provider.opt.myspeed": "MySpeed", "network.provider.opt.speedtest-tracker": "Speedtest Tracker", diff --git a/ui/widgets/system-summary/i18n/fr.json b/ui/widgets/system-summary/i18n/fr.json index d9f5a21d..e91d29ba 100644 --- a/ui/widgets/system-summary/i18n/fr.json +++ b/ui/widgets/system-summary/i18n/fr.json @@ -52,6 +52,8 @@ "network.mode.opt.speed": "Débit", "network.mode.opt.throughput": "Trafic", "network.mode.opt.uptime": "Disponibilité", + "network.interface.label": "Interface", + "network.interface.hint": "Renseignez la connexion ci-dessus, puis chargez la liste.", "network.provider.label": "Fournisseur", "network.provider.opt.myspeed": "MySpeed", "network.provider.opt.speedtest-tracker": "Speedtest Tracker", diff --git a/ui/widgets/system-summary/i18n/zh-Hans.json b/ui/widgets/system-summary/i18n/zh-Hans.json index 41faa9e1..7888ef5a 100644 --- a/ui/widgets/system-summary/i18n/zh-Hans.json +++ b/ui/widgets/system-summary/i18n/zh-Hans.json @@ -52,6 +52,8 @@ "network.mode.opt.speed": "测速", "network.mode.opt.throughput": "吞吐量", "network.mode.opt.uptime": "在线时长", + "network.interface.label": "网络接口", + "network.interface.hint": "先填写上方连接信息,然后加载列表。", "network.provider.label": "提供方", "network.provider.opt.myspeed": "MySpeed", "network.provider.opt.speedtest-tracker": "Speedtest Tracker", diff --git a/ui/widgets/system-summary/index.html b/ui/widgets/system-summary/index.html index 987bb4e7..18591d54 100644 --- a/ui/widgets/system-summary/index.html +++ b/ui/widgets/system-summary/index.html @@ -89,10 +89,14 @@ const TICK_MS = 10000; const SPEED_MS = 60000; const THROUGHPUT_MS = 3000; +/* Every tick is a call to someone else's server, so a remote source is read at + the same rate as the rest of the widget rather than every three seconds. */ +const REMOTE_THROUGHPUT_MS = 10000; const id = new URLSearchParams(location.search).get('id') || ''; const API = '/api'; let cfg = null; /* widget config from /api/widget-config/:id */ +const isRemote = () => (cfg?.widgetConfig?.statProvider || 'system') !== 'system'; let stats = null; /* latest /api/system-stats response */ let speed = null; /* latest myspeed response */ @@ -119,7 +123,8 @@ if (net.enabled && netMode === 'speed') poll({ fetch: fetchSpeed, interval: SPEED_MS, render: applySpeed, onError: () => {} }); if (net.enabled && netMode === 'throughput') - poll({ fetch: fetchThroughput, interval: THROUGHPUT_MS, render: applyThroughput, onError: () => {} }); + poll({ fetch: fetchThroughput, interval: isRemote() ? REMOTE_THROUGHPUT_MS : THROUGHPUT_MS, + render: applyThroughput, onError: () => {} }); poll({ fetch: fetchStats, interval: TICK_MS, render: d => { stats = d; applyStats(stats); } }); @@ -424,6 +429,9 @@ } async function fetchThroughput() { + /* A remote source answers through the widget's own data function. Only this + machine's traffic comes from the dashboard's cached reading. */ + if (isRemote()) return fetchData('throughput'); const r = await fetch(`${API}/network-stats`, { cache: 'no-store' }); if (!r.ok) throw new Error('HTTP ' + r.status); const d = await r.json(); @@ -431,9 +439,10 @@ return d; } +/* A source reporting totals has no rate to give until it has been read twice. */ function applyThroughput(d) { - const dn = document.getElementById('net-dn'); if (dn) dn.textContent = fmtRate(d.rx); - const up = document.getElementById('net-up'); if (up) up.textContent = fmtRate(d.tx); + const dn = document.getElementById('net-dn'); if (dn) dn.textContent = d ? fmtRate(d.rx) : '—'; + const up = document.getElementById('net-up'); if (up) up.textContent = d ? fmtRate(d.tx) : '—'; } async function fetchSpeed() { diff --git a/ui/widgets/system-summary/widget.json b/ui/widgets/system-summary/widget.json index bf908e0f..f9a9f4eb 100644 --- a/ui/widgets/system-summary/widget.json +++ b/ui/widgets/system-summary/widget.json @@ -523,7 +523,293 @@ "equals": "myspeed" } } - ] + ], + "showIf": { + "field": "statProvider", + "equals": "system" + } + }, + { + "key": "network", + "type": "object", + "label": "network.label", + "fields": [ + { + "key": "enabled", + "type": "toggle", + "label": "network.enabled.label", + "default": false + }, + { + "key": "mode", + "type": "select", + "variant": "pills", + "label": "network.mode.label", + "default": "speed", + "showIf": { + "field": "enabled", + "equals": true + }, + "options": [ + { + "value": "speed", + "label": "network.mode.opt.speed" + }, + { + "value": "throughput", + "label": "network.mode.opt.throughput" + }, + { + "value": "uptime", + "label": "network.mode.opt.uptime" + } + ] + }, + { + "key": "interface", + "type": "select", + "label": "network.interface.label", + "optionsFrom": "interfaces", + "hint": "network.interface.hint", + "showIf": { + "field": "mode", + "equals": "throughput" + } + }, + { + "key": "provider", + "type": "select", + "variant": "pills", + "label": "network.provider.label", + "default": "myspeed", + "showIf": { + "field": "mode", + "equals": "speed" + }, + "options": [ + { + "value": "myspeed", + "label": "network.provider.opt.myspeed" + }, + { + "value": "speedtest-tracker", + "label": "network.provider.opt.speedtest-tracker" + } + ] + }, + { + "key": "url", + "type": "text", + "label": "network.url.label", + "placeholder": "network.url.placeholder", + "showIf": { + "field": "mode", + "equals": "speed" + } + }, + { + "key": "myspeedPass", + "type": "secret", + "label": "network.myspeedPass.label", + "optional": true, + "showIf": { + "field": "provider", + "equals": "myspeed" + } + } + ], + "showIf": { + "field": "statProvider", + "equals": "glances" + } + }, + { + "key": "network", + "type": "object", + "label": "network.label", + "fields": [ + { + "key": "enabled", + "type": "toggle", + "label": "network.enabled.label", + "default": false + }, + { + "key": "mode", + "type": "select", + "variant": "pills", + "label": "network.mode.label", + "default": "speed", + "showIf": { + "field": "enabled", + "equals": true + }, + "options": [ + { + "value": "speed", + "label": "network.mode.opt.speed" + }, + { + "value": "throughput", + "label": "network.mode.opt.throughput" + }, + { + "value": "uptime", + "label": "network.mode.opt.uptime" + } + ] + }, + { + "key": "interface", + "type": "select", + "label": "network.interface.label", + "optionsFrom": "interfaces", + "hint": "network.interface.hint", + "showIf": { + "field": "mode", + "equals": "throughput" + } + }, + { + "key": "provider", + "type": "select", + "variant": "pills", + "label": "network.provider.label", + "default": "myspeed", + "showIf": { + "field": "mode", + "equals": "speed" + }, + "options": [ + { + "value": "myspeed", + "label": "network.provider.opt.myspeed" + }, + { + "value": "speedtest-tracker", + "label": "network.provider.opt.speedtest-tracker" + } + ] + }, + { + "key": "url", + "type": "text", + "label": "network.url.label", + "placeholder": "network.url.placeholder", + "showIf": { + "field": "mode", + "equals": "speed" + } + }, + { + "key": "myspeedPass", + "type": "secret", + "label": "network.myspeedPass.label", + "optional": true, + "showIf": { + "field": "provider", + "equals": "myspeed" + } + } + ], + "showIf": { + "field": "statProvider", + "equals": "beszel" + } + }, + { + "key": "network", + "type": "object", + "label": "network.label", + "fields": [ + { + "key": "enabled", + "type": "toggle", + "label": "network.enabled.label", + "default": false + }, + { + "key": "mode", + "type": "select", + "variant": "pills", + "label": "network.mode.label", + "default": "speed", + "showIf": { + "field": "enabled", + "equals": true + }, + "options": [ + { + "value": "speed", + "label": "network.mode.opt.speed" + }, + { + "value": "throughput", + "label": "network.mode.opt.throughput" + }, + { + "value": "uptime", + "label": "network.mode.opt.uptime" + } + ] + }, + { + "key": "interface", + "type": "select", + "label": "network.interface.label", + "optionsFrom": "interfaces", + "hint": "network.interface.hint", + "showIf": { + "field": "mode", + "equals": "throughput" + } + }, + { + "key": "provider", + "type": "select", + "variant": "pills", + "label": "network.provider.label", + "default": "myspeed", + "showIf": { + "field": "mode", + "equals": "speed" + }, + "options": [ + { + "value": "myspeed", + "label": "network.provider.opt.myspeed" + }, + { + "value": "speedtest-tracker", + "label": "network.provider.opt.speedtest-tracker" + } + ] + }, + { + "key": "url", + "type": "text", + "label": "network.url.label", + "placeholder": "network.url.placeholder", + "showIf": { + "field": "mode", + "equals": "speed" + } + }, + { + "key": "myspeedPass", + "type": "secret", + "label": "network.myspeedPass.label", + "optional": true, + "showIf": { + "field": "provider", + "equals": "myspeed" + } + } + ], + "showIf": { + "field": "statProvider", + "equals": "unraid" + } } ] }