diff --git a/CHANGELOG.md b/CHANGELOG.md index c7185dc4..597c72d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c7822181..0e44c6b5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/test/system-summary-beszel-data.test.js b/api/test/system-summary-beszel-data.test.js new file mode 100644 index 00000000..17a97a3c --- /dev/null +++ b/api/test/system-summary-beszel-data.test.js @@ -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' }, + ]); +}); diff --git a/ui/widgets/system-summary/data.js b/ui/widgets/system-summary/data.js index ebf84e97..30c2eb18 100644 --- a/ui/widgets/system-summary/data.js +++ b/ui/widgets/system-summary/data.js @@ -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); }; @@ -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 }) { @@ -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, + }; +} diff --git a/ui/widgets/system-summary/i18n/de.json b/ui/widgets/system-summary/i18n/de.json index 0eef4810..addd633b 100644 --- a/ui/widgets/system-summary/i18n/de.json +++ b/ui/widgets/system-summary/i18n/de.json @@ -3,10 +3,18 @@ "statProvider.label": "Quelle", "statProvider.opt.system": "Dieser Rechner", "statProvider.opt.glances": "Glances", + "statProvider.opt.beszel": "Beszel", "glancesUrl.label": "Glances-URL", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Benutzername", "glancesPass.label": "Passwort", + "beszelUrl.label": "Beszel-URL", + "beszelUrl.placeholder": "beszel:8090", + "beszelUser.label": "Konto", + "beszelUser.placeholder": "du@example.com", + "beszelPass.label": "Passwort", + "beszelSystem.label": "System", + "beszelSystem.hint": "Oben URL und Konto eintragen, dann die Liste laden.", "slots.label": "Slot", "slots.type.label": "Ressource", "slots.type.opt.cpu": "CPU", @@ -19,10 +27,15 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "Zweiter Einhängepunkt", "slots.secondary.placeholder": "/mnt/data", + "slots.fsPrimary.label": "Erstes Dateisystem", + "slots.fsPrimary.placeholder": "root", + "slots.fsSecondary.label": "Zweites Dateisystem", + "slots.fsSecondary.placeholder": "media", "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.", "network.label": "Netzwerk", "network.enabled.label": "Netzwerkzeile anzeigen", "network.mode.label": "Modus", diff --git a/ui/widgets/system-summary/i18n/en.json b/ui/widgets/system-summary/i18n/en.json index 216d58e3..122976e3 100644 --- a/ui/widgets/system-summary/i18n/en.json +++ b/ui/widgets/system-summary/i18n/en.json @@ -3,10 +3,18 @@ "statProvider.label": "Source", "statProvider.opt.system": "This Machine", "statProvider.opt.glances": "Glances", + "statProvider.opt.beszel": "Beszel", "glancesUrl.label": "Glances URL", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Username", "glancesPass.label": "Password", + "beszelUrl.label": "Beszel URL", + "beszelUrl.placeholder": "beszel:8090", + "beszelUser.label": "Account", + "beszelUser.placeholder": "you@example.com", + "beszelPass.label": "Password", + "beszelSystem.label": "System", + "beszelSystem.hint": "Enter the URL and account above, then load the list.", "slots.label": "Slot", "slots.type.label": "Resource", "slots.type.opt.cpu": "CPU", @@ -19,10 +27,15 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "Second Mount Path", "slots.secondary.placeholder": "/mnt/data", + "slots.fsPrimary.label": "First Filesystem", + "slots.fsPrimary.placeholder": "root", + "slots.fsSecondary.label": "Second Filesystem", + "slots.fsSecondary.placeholder": "media", "slots.thermalZone.label": "Thermal Zone", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Enter the URL above, then load the list.", "slots.color.label": "Color", + "slots.beszelHint": "Beszel does not report a process count.", "network.label": "Network", "network.enabled.label": "Show Network Row", "network.mode.label": "Mode", diff --git a/ui/widgets/system-summary/i18n/es.json b/ui/widgets/system-summary/i18n/es.json index ff45d411..d3bba69f 100644 --- a/ui/widgets/system-summary/i18n/es.json +++ b/ui/widgets/system-summary/i18n/es.json @@ -3,10 +3,18 @@ "statProvider.label": "Origen", "statProvider.opt.system": "Este equipo", "statProvider.opt.glances": "Glances", + "statProvider.opt.beszel": "Beszel", "glancesUrl.label": "URL de Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Usuario", "glancesPass.label": "Contraseña", + "beszelUrl.label": "URL de Beszel", + "beszelUrl.placeholder": "beszel:8090", + "beszelUser.label": "Cuenta", + "beszelUser.placeholder": "tu@example.com", + "beszelPass.label": "Contraseña", + "beszelSystem.label": "Sistema", + "beszelSystem.hint": "Introduce la URL y la cuenta de arriba y carga la lista.", "slots.label": "Ranura", "slots.type.label": "Recurso", "slots.type.opt.cpu": "CPU", @@ -19,10 +27,15 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "Segunda ruta de montaje", "slots.secondary.placeholder": "/mnt/data", + "slots.fsPrimary.label": "Primer sistema de archivos", + "slots.fsPrimary.placeholder": "root", + "slots.fsSecondary.label": "Segundo sistema de archivos", + "slots.fsSecondary.placeholder": "media", "slots.thermalZone.label": "Zona térmica", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Introduce la URL de arriba y carga la lista.", "slots.color.label": "Color", + "slots.beszelHint": "Beszel no informa del número de procesos.", "network.label": "Red", "network.enabled.label": "Mostrar la fila de red", "network.mode.label": "Modo", diff --git a/ui/widgets/system-summary/i18n/fa.json b/ui/widgets/system-summary/i18n/fa.json index 53001076..10b5ee7a 100644 --- a/ui/widgets/system-summary/i18n/fa.json +++ b/ui/widgets/system-summary/i18n/fa.json @@ -3,10 +3,18 @@ "statProvider.label": "منبع", "statProvider.opt.system": "همین دستگاه", "statProvider.opt.glances": "Glances", + "statProvider.opt.beszel": "Beszel", "glancesUrl.label": "نشانی Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "نام کاربری", "glancesPass.label": "گذرواژه", + "beszelUrl.label": "نشانی Beszel", + "beszelUrl.placeholder": "beszel:8090", + "beszelUser.label": "حساب", + "beszelUser.placeholder": "you@example.com", + "beszelPass.label": "گذرواژه", + "beszelSystem.label": "سامانه", + "beszelSystem.hint": "نشانی و حساب بالا را وارد کنید، سپس فهرست را بارگیری کنید.", "slots.label": "جایگاه", "slots.type.label": "منبع", "slots.type.opt.cpu": "CPU", @@ -19,10 +27,15 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "دومین مسیر اتصال", "slots.secondary.placeholder": "/mnt/data", + "slots.fsPrimary.label": "نخستین سامانهٔ پرونده", + "slots.fsPrimary.placeholder": "root", + "slots.fsSecondary.label": "دومین سامانهٔ پرونده", + "slots.fsSecondary.placeholder": "media", "slots.thermalZone.label": "ناحیهٔ حرارتی", "slots.sensor.label": "حسگر", "slots.sensor.hint": "نشانی بالا را وارد کنید، سپس فهرست را بارگیری کنید.", "slots.color.label": "رنگ", + "slots.beszelHint": "Beszel شمار فرایندها را گزارش نمی‌کند.", "network.label": "شبکه", "network.enabled.label": "نمایش ردیف شبکه", "network.mode.label": "حالت", diff --git a/ui/widgets/system-summary/i18n/fr.json b/ui/widgets/system-summary/i18n/fr.json index 3c44f6bd..f30ce3ac 100644 --- a/ui/widgets/system-summary/i18n/fr.json +++ b/ui/widgets/system-summary/i18n/fr.json @@ -3,10 +3,18 @@ "statProvider.label": "Source", "statProvider.opt.system": "Cette machine", "statProvider.opt.glances": "Glances", + "statProvider.opt.beszel": "Beszel", "glancesUrl.label": "URL Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Nom d'utilisateur", "glancesPass.label": "Mot de passe", + "beszelUrl.label": "URL Beszel", + "beszelUrl.placeholder": "beszel:8090", + "beszelUser.label": "Compte", + "beszelUser.placeholder": "vous@example.com", + "beszelPass.label": "Mot de passe", + "beszelSystem.label": "Système", + "beszelSystem.hint": "Saisissez l'URL et le compte ci-dessus, puis chargez la liste.", "slots.label": "Emplacement", "slots.type.label": "Ressource", "slots.type.opt.cpu": "CPU", @@ -19,10 +27,15 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "Second point de montage", "slots.secondary.placeholder": "/mnt/data", + "slots.fsPrimary.label": "Premier système de fichiers", + "slots.fsPrimary.placeholder": "root", + "slots.fsSecondary.label": "Deuxième système de fichiers", + "slots.fsSecondary.placeholder": "media", "slots.thermalZone.label": "Zone thermique", "slots.sensor.label": "Capteur", "slots.sensor.hint": "Saisissez l'URL ci-dessus, puis chargez la liste.", "slots.color.label": "Couleur", + "slots.beszelHint": "Beszel ne rapporte pas le nombre de processus.", "network.label": "Réseau", "network.enabled.label": "Afficher la ligne réseau", "network.mode.label": "Mode", diff --git a/ui/widgets/system-summary/i18n/zh-Hans.json b/ui/widgets/system-summary/i18n/zh-Hans.json index 9f934786..425fb47a 100644 --- a/ui/widgets/system-summary/i18n/zh-Hans.json +++ b/ui/widgets/system-summary/i18n/zh-Hans.json @@ -3,10 +3,18 @@ "statProvider.label": "数据源", "statProvider.opt.system": "本机", "statProvider.opt.glances": "Glances", + "statProvider.opt.beszel": "Beszel", "glancesUrl.label": "Glances 地址", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "用户名", "glancesPass.label": "密码", + "beszelUrl.label": "Beszel 地址", + "beszelUrl.placeholder": "beszel:8090", + "beszelUser.label": "账户", + "beszelUser.placeholder": "you@example.com", + "beszelPass.label": "密码", + "beszelSystem.label": "系统", + "beszelSystem.hint": "先填写上方地址和账户,然后加载列表。", "slots.label": "插槽", "slots.type.label": "资源", "slots.type.opt.cpu": "CPU", @@ -19,10 +27,15 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "第二个挂载路径", "slots.secondary.placeholder": "/mnt/data", + "slots.fsPrimary.label": "第一个文件系统", + "slots.fsPrimary.placeholder": "root", + "slots.fsSecondary.label": "第二个文件系统", + "slots.fsSecondary.placeholder": "media", "slots.thermalZone.label": "温度区域", "slots.sensor.label": "传感器", "slots.sensor.hint": "先填写上方地址,然后加载列表。", "slots.color.label": "颜色", + "slots.beszelHint": "Beszel 不报告进程数。", "network.label": "网络", "network.enabled.label": "显示网络行", "network.mode.label": "模式", diff --git a/ui/widgets/system-summary/widget.json b/ui/widgets/system-summary/widget.json index 666d8763..de61fb1e 100644 --- a/ui/widgets/system-summary/widget.json +++ b/ui/widgets/system-summary/widget.json @@ -20,6 +20,10 @@ { "value": "glances", "label": "statProvider.opt.glances" + }, + { + "value": "beszel", + "label": "statProvider.opt.beszel" } ] }, @@ -53,6 +57,46 @@ "equals": "glances" } }, + { + "key": "beszelUrl", + "type": "text", + "label": "beszelUrl.label", + "placeholder": "beszelUrl.placeholder", + "showIf": { + "field": "statProvider", + "equals": "beszel" + } + }, + { + "key": "beszelUser", + "type": "text", + "label": "beszelUser.label", + "placeholder": "beszelUser.placeholder", + "showIf": { + "field": "statProvider", + "equals": "beszel" + } + }, + { + "key": "beszelPass", + "type": "secret", + "label": "beszelPass.label", + "showIf": { + "field": "statProvider", + "equals": "beszel" + } + }, + { + "key": "beszelSystem", + "type": "select", + "label": "beszelSystem.label", + "optionsFrom": "systems", + "hint": "beszelSystem.hint", + "showIf": { + "field": "statProvider", + "equals": "beszel" + } + }, { "key": "slots", "type": "group", @@ -220,6 +264,87 @@ "equals": "glances" } }, + { + "key": "slots", + "type": "group", + "label": "slots.label", + "min": 3, + "max": 3, + "fields": [ + { + "key": "type", + "type": "select", + "label": "slots.type.label", + "default": "cpu", + "options": [ + { + "value": "cpu", + "label": "slots.type.opt.cpu" + }, + { + "value": "ram", + "label": "slots.type.opt.ram" + }, + { + "value": "temp", + "label": "slots.type.opt.temp" + }, + { + "value": "disk", + "label": "slots.type.opt.disk" + }, + { + "value": "iowait", + "label": "slots.type.opt.iowait" + } + ] + }, + { + "key": "primary", + "type": "text", + "label": "slots.fsPrimary.label", + "default": "/", + "placeholder": "slots.fsPrimary.placeholder", + "showIf": { + "field": "type", + "equals": "disk" + } + }, + { + "key": "secondary", + "type": "text", + "label": "slots.fsSecondary.label", + "placeholder": "slots.fsSecondary.placeholder", + "optional": true, + "showIf": { + "field": "type", + "equals": "disk" + } + }, + { + "key": "sensor", + "type": "select", + "label": "slots.sensor.label", + "optionsFrom": "sensors", + "hint": "slots.sensor.hint", + "showIf": { + "field": "type", + "equals": "temp" + } + }, + { + "key": "color", + "type": "color", + "label": "slots.color.label", + "default": "#0091FF" + } + ], + "showIf": { + "field": "statProvider", + "equals": "beszel" + }, + "hint": "slots.beszelHint" + }, { "key": "network", "type": "object",