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" + } } ] }