diff --git a/README.md b/README.md index 2f59540c..bcb8fdc8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,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, Beszel or the Unraid API +- **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, Beszel, the Unraid API or Proxmox VE - **Disk health**: TrueNAS, Scrutiny - **Backup**: Duplicati, Kopia - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami diff --git a/api/src/demo-data.js b/api/src/demo-data.js index 8a1467b9..901f1d5d 100644 --- a/api/src/demo-data.js +++ b/api/src/demo-data.js @@ -73,6 +73,12 @@ function demoHealth(items) { return out; } +const demoProxmoxGuests = () => [ + { vmid: 100, name: 'home-assistant', type: 'qemu', node: 'pve' }, + { vmid: 101, name: 'docker-host', type: 'qemu', node: 'pve' }, + { vmid: 200, name: 'pihole', type: 'lxc', node: 'pve' }, +]; + const helpers = { wave, round }; -module.exports = { metrics, helpers, demoBadges, demoHealth, CURVES }; +module.exports = { metrics, helpers, demoBadges, demoHealth, demoProxmoxGuests, CURVES }; diff --git a/api/src/proxmox.js b/api/src/proxmox.js new file mode 100644 index 00000000..5dfd30a0 --- /dev/null +++ b/api/src/proxmox.js @@ -0,0 +1,156 @@ +const { fetchUnchecked, pingErrorText } = require('./proxy'); +const { KIND } = require('./api-error'); +const { latest } = require('./latest'); +const { BATCH_MS, PING_MS } = require('./timeouts'); + +const GUESTS_KEEP_MS = 2 * 60 * 1000; + +/* user@realm!tokenid. Anything else in the header value is refused by Proxmox, + and a line break would split the request. */ +const TOKEN_ID = /^[^\s@!=]+@[^\s@!=]+![A-Za-z][A-Za-z0-9._-]*$/; +const SECRET = /^[A-Za-z0-9-]{1,128}$/; + +const str = v => (typeof v === 'string' ? v.trim() : ''); + +/** @param {any} server @returns {{ url: string, tokenId: string, secret: string, skipTlsVerify: boolean } | null} */ +function connectionOf(server) { + const p = server?.proxmox; + if (!p || typeof p !== 'object') return null; + const url = str(p.url) + .replace(/[?#].*$/, '') + .replace(/\/+$/, ''); + const tokenId = str(p.tokenId); + const secret = str(p.secret); + if (!url || !tokenId || !secret) return null; + return { url, tokenId, secret, skipTlsVerify: p.skipTlsVerify === true }; +} + +/* The message reaches the browser verbatim. Never put upstream text in it. */ +class ProxmoxError extends Error { + /** @param {string} message @param {string} kind @param {string} apiCode @param {Record} [detail] */ + constructor(message, kind, apiCode, detail) { + super(message); + this.vouchedMessage = message; + this.kind = kind; + this.apiCode = apiCode; + if (detail) this.detail = detail; + } +} + +const badToken = () => + new ProxmoxError('The token ID must look like user@realm!name.', KIND.INVALID, 'invalid.proxmox-token'); +const badUrl = () => new ProxmoxError('That is not a valid URL.', KIND.INVALID, 'invalid.url'); + +/** @param {{ url: string, tokenId: string, secret: string, skipTlsVerify: boolean }} conn @param {string} path */ +async function pveGet(conn, path, timeout = BATCH_MS) { + if (!TOKEN_ID.test(conn.tokenId) || !SECRET.test(conn.secret)) throw badToken(); + let base; + try { + base = new URL(conn.url); + } catch { + throw badUrl(); + } + if (base.protocol !== 'https:' && base.protocol !== 'http:') throw badUrl(); + const r = await fetchUnchecked(`${conn.url}/api2/json${path}`, { + headers: { Authorization: `PVEAPIToken=${conn.tokenId}=${conn.secret}` }, + timeout, + skipTls: conn.skipTlsVerify, + }); + if (r.status === 401) throw new ProxmoxError('Proxmox refused the token.', KIND.UPSTREAM, 'upstream.proxmox-refused'); + if (r.status === 403) + throw new ProxmoxError( + 'The token is missing a permission. Give it the PVEAuditor role.', + KIND.UPSTREAM, + 'upstream.proxmox-permission', + ); + if (r.status >= 400) + throw new ProxmoxError(`Proxmox answered with HTTP ${r.status}.`, KIND.UPSTREAM, 'upstream.status', { + status: r.status, + }); + if (!r.data || typeof r.data !== 'object' || !('data' in r.data)) + throw new ProxmoxError( + 'Something is listening there, but it is not Proxmox.', + KIND.UPSTREAM, + 'upstream.not-proxmox', + ); + return r.data.data; +} + +/** @returns {Promise>} */ +async function listGuests(conn, timeout) { + const rows = await pveGet(conn, '/cluster/resources?type=vm', timeout); + return (Array.isArray(rows) ? rows : []) + .filter(g => g && (g.type === 'qemu' || g.type === 'lxc') && Number.isInteger(g.vmid) && !g.template) + .map(g => ({ + vmid: g.vmid, + name: typeof g.name === 'string' && g.name ? g.name : String(g.vmid), + type: g.type, + node: typeof g.node === 'string' ? g.node : '', + status: typeof g.status === 'string' ? g.status : 'unknown', + })) + .sort((a, b) => a.vmid - b.vmid); +} + +/** @returns {Promise<{ guests: Map } | { error: string, code?: string }>} */ +async function guestStates(conn) { + const key = `proxmox:${conn.url}|${conn.tokenId}|${conn.skipTlsVerify}`; + const o = await latest(key, () => listGuests(conn, BATCH_MS), { holdMs: BATCH_MS, keepMs: GUESTS_KEEP_MS }); + if (!o) return { error: 'Proxmox did not answer in time.', code: 'timeout.no-answer' }; + if (!o.ok) + return o.error instanceof ProxmoxError + ? { error: o.error.message, code: o.error.apiCode } + : { error: pingErrorText(o.error) }; + return { guests: new Map(o.value.map(g => [g.vmid, { status: g.status }])) }; +} + +/* ── stored secret ─────────────────────────────────────────────────────── */ + +/** @param {any} server */ +function scrubProxmox(server) { + const p = server?.proxmox; + if (!p || typeof p !== 'object') return; + const set = !!str(p.secret); + delete p.secret; + p.secretSet = set; +} + +/** Refills a blank secret only for the same address, token and certificate + setting. Returns true when a stored secret was dropped. + @param {any} server @param {any} prevServer @param {boolean} redirected */ +function preserveProxmox(server, prevServer, redirected) { + if (!server || typeof server !== 'object') return false; + const p = server.proxmox; + if (p == null) return false; + if (typeof p !== 'object' || Array.isArray(p)) { + delete server.proxmox; + return false; + } + const next = { url: str(p.url), tokenId: str(p.tokenId), skipTlsVerify: p.skipTlsVerify === true }; + const typed = str(p.secret); + const old = prevServer?.proxmox && typeof prevServer.proxmox === 'object' ? prevServer.proxmox : null; + const oldSecret = str(old?.secret); + let dropped = false; + if (typed) next.secret = typed; + else if (oldSecret) { + const same = + !redirected && + str(old.url) === next.url && + str(old.tokenId) === next.tokenId && + (old.skipTlsVerify === true) === next.skipTlsVerify; + if (same) next.secret = oldSecret; + else dropped = true; + } + server.proxmox = next; + return dropped; +} + +module.exports = { + connectionOf, + pveGet, + listGuests, + guestStates, + scrubProxmox, + preserveProxmox, + ProxmoxError, + TEST_MS: PING_MS, +}; diff --git a/api/src/routes/config.js b/api/src/routes/config.js index 52d76809..71c2bda7 100644 --- a/api/src/routes/config.js +++ b/api/src/routes/config.js @@ -14,6 +14,7 @@ const backoff = require('../poll-backoff'); const { stripDisabledCredentials } = require('../auth'); const { pruneWallpapers } = require('./wallpaper'); const { normalizeHostList } = require('../../../ui/js/host-names.js'); +const { scrubProxmox, preserveProxmox } = require('../proxmox'); /* Real configs nest under 10 levels. Far deeper input is written to disk and then breaks every read of the config. */ @@ -29,6 +30,7 @@ function scrubSecrets(cfg) { const safe = structuredClone(cfg); scrubAllSecrets(safe); if (safe.settings?.background?.apiKey) delete safe.settings.background.apiKey; + scrubProxmox(safe.settings?.server); if (safe.settings?.auth) { delete safe.settings.auth.secret; delete safe.settings.auth.passwordHash; @@ -204,6 +206,7 @@ on('POST', '/api/config', async (req, res) => { if (!list) return json(res, 400, { error: 'allowedHosts must be a list of host names', kind: KIND.INVALID }); server.allowedHosts = list; } + const proxmoxDropped = preserveProxmox(server, existing.settings?.server, redirected); data.settings.server = server; /* Migrate first. A migration that renames a widget type after the refill stores secrets the new manifest does not scrub. */ @@ -211,6 +214,7 @@ on('POST', '/api/config', async (req, res) => { const { withheld } = preserveAllSecrets(data, existing); if (redirected && existing.settings?.background?.apiKey && !data.settings?.background?.apiKey) withheld.push({ id: 'background', label: 'Unsplash' }); + if (proxmoxDropped) withheld.push({ id: 'proxmox', label: 'Proxmox' }); ensureSystemItems(data); saveConfig(data); /* Only now: until the config points at it, the file being replaced is still diff --git a/api/src/routes/health.js b/api/src/routes/health.js index d1f4050f..3bba41ce 100644 --- a/api/src/routes/health.js +++ b/api/src/routes/health.js @@ -19,9 +19,20 @@ const { latest } = require('../latest'); const { IS_DEMO } = require('../demo'); const demoData = require('../demo-data'); const log = require('../log'); +const proxmox = require('../proxmox'); +const { RETYPE_MESSAGE } = require('../secret-scope'); +const { errorBody } = require('../api-error'); const SOCKET_PROXY_URL_DEFAULT = process.env.SOCKET_PROXY_URL || ''; const CONTAINERS_KEEP_MS = 2 * 60 * 1000; +/** @param {any} item */ +function guestOf(item) { + const hc = item?.monitoring?.healthcheck; + if (!hc?.enabled) return null; + const id = Number(hc.proxmoxGuest); + return Number.isInteger(id) && id > 0 ? id : null; +} + function socketProxyUrl(cfg) { return cfg.settings?.server?.socketProxyUrl || SOCKET_PROXY_URL_DEFAULT; } @@ -139,6 +150,55 @@ on('POST', '/api/docker/test', async (req, res) => { } }); +const MISSING = Object.freeze({ + error: 'Enter the Proxmox address, token ID and secret.', + kind: KIND.INVALID, + code: 'invalid.proxmox-missing', +}); + +/* A test that leaves the secret blank uses the stored one, but only for the + address and token it was stored for. */ +on('POST', '/api/proxmox/test', async (req, res) => { + if (!checkOrigin(req, res)) return; + const limited = rateLimit(getIp(req), 'proxmox-test', 20, 60_000); + if (limited) return json(res, 429, { ok: false, error: limited, kind: KIND.BLOCKED, code: 'blocked.rate-limit' }); + let body; + try { + body = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { ok: false, error: 'url and tokenId required', kind: KIND.INVALID }); + } + const server = { + proxmox: { url: body?.url, tokenId: body?.tokenId, secret: body?.secret, skipTlsVerify: body?.skipTlsVerify }, + }; + const dropped = proxmox.preserveProxmox(server, loadConfig().settings?.server, false); + if (!server.proxmox.secret && dropped) + return json(res, 200, { ok: false, error: RETYPE_MESSAGE, kind: KIND.INVALID, code: 'invalid.retype' }); + const conn = proxmox.connectionOf(server); + if (!conn) return json(res, 200, { ok: false, ...MISSING }); + if (IS_DEMO) return json(res, 200, { ok: true, guests: 0 }); + try { + const guests = await proxmox.listGuests(conn, proxmox.TEST_MS); + json(res, 200, { ok: true, guests: guests.length }); + } catch (e) { + json(res, 200, { ok: false, ...errorBody(e) }); + } +}); + +on('GET', '/api/proxmox/guests', async (req, res) => { + const limited = rateLimit(getIp(req), 'proxmox-guests', 30, 60_000); + if (limited) return json(res, 429, { error: limited, kind: KIND.BLOCKED, code: 'blocked.rate-limit' }); + if (IS_DEMO) return json(res, 200, { guests: demoData.demoProxmoxGuests() }); + const conn = proxmox.connectionOf(loadConfig().settings?.server); + if (!conn) return json(res, 400, MISSING); + try { + const guests = await proxmox.listGuests(conn, proxmox.TEST_MS); + json(res, 200, { guests: guests.map(({ vmid, name, type, node }) => ({ vmid, name, type, node })) }); + } catch (e) { + json(res, 502, errorBody(e)); + } +}); + on('GET', '/health', (_, res) => json(res, 200, { ok: true })); on('GET', '/api/health', async (req, res) => { @@ -149,6 +209,7 @@ on('GET', '/api/health', async (req, res) => { return json(res, 200, demoData.demoHealth(cfg.items)); } const cfg = loadConfig(); + if (cfg.settings?.server?.healthChecks === false) return json(res, 200, {}); const socketUrl = socketProxyUrl(cfg); /* Without a proxy address every container lookup misses, which would report every container-backed app as unhealthy. */ @@ -158,6 +219,9 @@ on('GET', '/api/health', async (req, res) => { keepMs: CONTAINERS_KEEP_MS, }).then(o => (o?.ok ? o.value : Object.create(null))) : Promise.resolve(Object.create(null)); + const pve = proxmox.connectionOf(cfg.settings?.server); + const wantsGuests = cfg.items.some(i => i.type === 'app' && guestOf(i) != null); + const guestsReady = pve && wantsGuests ? proxmox.guestStates(pve) : null; const result = Object.create(null); await Promise.allSettled( cfg.items @@ -166,6 +230,7 @@ on('GET', '/api/health', async (req, res) => { const mon = item.monitoring?.healthcheck || {}; const cName = socketUrl ? mon.container || item.container || '' : ''; const ping = mon.pingUrl || item.ping || ''; + const vmid = guestOf(item); /* Build the entry up rather than replace it. An item can have both a container and a ping, and the tile needs the detail from each. */ let unhealthy = false; @@ -193,7 +258,21 @@ on('GET', '/api/health', async (req, res) => { detail.pingStatus = r.status; detail.pingError = r.error; } - if (cName || ping) result[item.id] = { unhealthy, ...detail }; + if (vmid != null) { + const r = guestsReady + ? await guestsReady + : { error: 'Proxmox is not set up in Settings.', code: 'invalid.proxmox-missing' }; + if ('error' in r) { + unhealthy = true; + detail.proxmoxError = r.error; + if (r.code) detail.proxmoxCode = r.code; + } else { + const g = r.guests.get(vmid); + detail.guestState = g ? g.status : 'missing'; + if (detail.guestState !== 'running') unhealthy = true; + } + } + if (cName || ping || vmid != null) result[item.id] = { unhealthy, ...detail }; }), ); json(res, 200, result); diff --git a/api/test/proxmox-health.test.js b/api/test/proxmox-health.test.js new file mode 100644 index 00000000..0b2c7247 --- /dev/null +++ b/api/test/proxmox-health.test.js @@ -0,0 +1,253 @@ +const path = require('node:path'); + +const { tmpDir } = require('../test-support/tmp'); +process.env.CONFIG_PATH = path.join(tmpDir('proxmox-health'), 'apps.json'); +delete process.env.SOCKET_PROXY_URL; + +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +require('../src/routes'); +const { dispatch } = require('../src/router'); +const { saveConfig, loadConfig } = require('../src/config'); +const latest = require('../src/latest'); + +const TOKEN = 'stackyard@pve!dash'; +const SECRET = '0b5f7c2e-1d3a-4c6b-9e8f-2a7d4b1c0e9f'; + +let server, base, pve, pveBase; +let guests = []; +let pveStatus = 200; +const seen = []; + +const listen = s => new Promise(r => s.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${s.address().port}`))); +const close = s => + new Promise(r => { + s.closeAllConnections?.(); + s.close(r); + }); + +before(async () => { + pve = http.createServer((req, res) => { + seen.push({ url: req.url, auth: req.headers.authorization }); + if (req.headers.authorization !== `PVEAPIToken=${TOKEN}=${SECRET}`) { + res.writeHead(401); + return res.end(); + } + res.writeHead(pveStatus, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: guests })); + }); + pveBase = await listen(pve); + server = http.createServer(dispatch); + base = await listen(server); +}); +after(async () => { + await close(server); + await close(pve); +}); +beforeEach(() => { + latest.reset(); + seen.length = 0; + pveStatus = 200; + guests = [ + { vmid: 100, name: 'ha', type: 'qemu', node: 'pve', status: 'running' }, + { vmid: 101, name: 'db', type: 'lxc', node: 'pve', status: 'stopped' }, + { vmid: 900, name: 'tmpl', type: 'qemu', node: 'pve', status: 'stopped', template: 1 }, + ]; +}); + +function request(method, pathname, body) { + const u = new URL(base + pathname); + const data = body == null ? null : JSON.stringify(body); + const headers = data + ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: base } + : {}; + return new Promise((resolve, reject) => { + const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method, headers }, res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) })); + }); + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +const app = (id, guest) => ({ + id, + type: 'app', + label: id, + monitoring: { healthcheck: { enabled: true, container: '', pingUrl: '', proxmoxGuest: guest } }, +}); + +function configure(items, extra = {}) { + saveConfig({ + items, + settings: { + server: { proxmox: { url: pveBase, tokenId: TOKEN, secret: SECRET }, ...extra }, + }, + }); +} + +test('a running guest is healthy and a stopped one is not', async () => { + configure([app('a', 100), app('b', 101)]); + const r = (await request('GET', '/api/health')).body; + assert.deepEqual(r.a, { unhealthy: false, guestState: 'running' }); + assert.deepEqual(r.b, { unhealthy: true, guestState: 'stopped' }); +}); + +test('every tile shares one request to Proxmox', async () => { + configure([app('a', 100), app('b', 101), app('c', 100)]); + await request('GET', '/api/health'); + assert.equal(seen.length, 1); + assert.equal(seen[0].url, '/api2/json/cluster/resources?type=vm'); +}); + +test('a guest Proxmox does not list is reported as not found', async () => { + configure([app('a', 555), app('t', 900)]); + const r = (await request('GET', '/api/health')).body; + assert.deepEqual(r.a, { unhealthy: true, guestState: 'missing' }); + assert.deepEqual(r.t, { unhealthy: true, guestState: 'missing' }, 'a template is never a guest to watch'); +}); + +/* Proxmox lists a guest as 'unknown', with no name or template flag, while its + node has not reported. */ +test('a guest Proxmox has no figures for is kept apart from a missing one', async () => { + guests = [{ id: 'qemu/100', vmid: 100, node: 'pve', type: 'qemu', status: 'unknown' }]; + configure([app('a', 100)]); + const r = (await request('GET', '/api/health')).body; + assert.deepEqual(r.a, { unhealthy: true, guestState: 'unknown' }); +}); + +test('an address copied from the Proxmox tab still reaches the API', async () => { + saveConfig({ + items: [app('a', 100)], + settings: { server: { proxmox: { url: `${pveBase}/#v1:0:18:4:::::::`, tokenId: TOKEN, secret: SECRET } } }, + }); + const r = (await request('GET', '/api/health')).body; + assert.deepEqual(r.a, { unhealthy: false, guestState: 'running' }); + assert.equal(seen[0].url, '/api2/json/cluster/resources?type=vm'); +}); + +test('a refused token turns the tile red with the reason', async () => { + saveConfig({ + items: [app('a', 100)], + settings: { server: { proxmox: { url: pveBase, tokenId: TOKEN, secret: '11111111-2222-3333-4444-555555555555' } } }, + }); + const r = (await request('GET', '/api/health')).body; + assert.equal(r.a.unhealthy, true); + assert.equal(r.a.proxmoxError, 'Proxmox refused the token.'); + assert.equal(r.a.proxmoxCode, 'upstream.proxmox-refused'); +}); + +test('a tile set to Proxmox with no connection says so', async () => { + saveConfig({ items: [app('a', 100)], settings: { server: {} } }); + const r = (await request('GET', '/api/health')).body; + assert.equal(r.a.unhealthy, true); + assert.match(r.a.proxmoxError, /not set up/); + assert.equal(r.a.proxmoxCode, 'invalid.proxmox-missing'); + assert.equal(seen.length, 0); +}); + +test('with health checks off nothing is checked', async () => { + configure([app('a', 101), { id: 'p', type: 'app', ping: pveBase }], { healthChecks: false }); + assert.deepEqual((await request('GET', '/api/health')).body, {}); + assert.equal(seen.length, 0); +}); + +test('a disabled check is not sent to Proxmox', async () => { + const off = app('a', 101); + off.monitoring.healthcheck.enabled = false; + configure([off]); + assert.deepEqual((await request('GET', '/api/health')).body, {}); +}); + +/* ── the stored secret ───────────────────────────────────────────────────── */ + +test('the secret never reaches the browser', async () => { + configure([]); + const cfg = (await request('GET', '/api/config')).body; + assert.equal(cfg.settings.server.proxmox.secret, undefined); + assert.equal(cfg.settings.server.proxmox.secretSet, true); + const exported = (await request('GET', '/api/config/export')).body; + assert.equal(exported.settings.server.proxmox.secret, undefined); +}); + +test('a save without the secret keeps it for the same address and token', async () => { + configure([]); + const cfg = (await request('GET', '/api/config')).body; + const r = await request('POST', '/api/config', cfg); + assert.equal(r.status, 200); + assert.equal(r.body.withheld, undefined); + assert.equal(loadConfig().settings.server.proxmox.secret, SECRET); + assert.equal(loadConfig().settings.server.proxmox.secretSet, undefined); +}); + +test('pointing the secret at another address drops it and says so', async () => { + configure([]); + for (const change of [ + { url: 'https://elsewhere.example:8006' }, + { tokenId: 'root@pam!other' }, + { skipTlsVerify: true }, + ]) { + configure([]); + const cfg = (await request('GET', '/api/config')).body; + Object.assign(cfg.settings.server.proxmox, change); + const r = await request('POST', '/api/config', cfg); + assert.deepEqual(r.body.withheld, [{ id: 'proxmox', label: 'Proxmox' }], JSON.stringify(change)); + assert.equal(loadConfig().settings.server.proxmox.secret, undefined); + } +}); + +/* ── the Test button and the guest list ──────────────────────────────────── */ + +test('the test counts the guests a tile can watch', async () => { + configure([]); + const r = await request('POST', '/api/proxmox/test', { url: pveBase, tokenId: TOKEN, secret: SECRET }); + assert.deepEqual(r.body, { ok: true, guests: 2 }); +}); + +test('a test with a blank secret uses the stored one only for the same address', async () => { + configure([]); + const same = await request('POST', '/api/proxmox/test', { url: pveBase, tokenId: TOKEN }); + assert.equal(same.body.ok, true); + const moved = await request('POST', '/api/proxmox/test', { url: 'http://127.0.0.1:9', tokenId: TOKEN }); + assert.equal(moved.body.code, 'invalid.retype'); +}); + +test('a test reports each failure as a code the admin translates', async () => { + saveConfig({ items: [], settings: { server: {} } }); + const cases = [ + [{ url: pveBase, tokenId: TOKEN }, 'invalid.proxmox-missing'], + [{ url: pveBase, tokenId: 'not-a-token', secret: SECRET }, 'invalid.proxmox-token'], + [{ url: pveBase, tokenId: TOKEN, secret: '11111111-2222-3333-4444-555555555555' }, 'upstream.proxmox-refused'], + ]; + for (const [body, code] of cases) { + const r = await request('POST', '/api/proxmox/test', body); + assert.equal(r.body.ok, false); + assert.equal(r.body.code, code, JSON.stringify(body)); + } + pveStatus = 403; + const denied = await request('POST', '/api/proxmox/test', { url: pveBase, tokenId: TOKEN, secret: SECRET }); + assert.equal(denied.body.code, 'upstream.proxmox-permission'); +}); + +test('the guest list names each guest without its state', async () => { + configure([]); + const r = await request('GET', '/api/proxmox/guests'); + assert.deepEqual(r.body.guests, [ + { vmid: 100, name: 'ha', type: 'qemu', node: 'pve' }, + { vmid: 101, name: 'db', type: 'lxc', node: 'pve' }, + ]); +}); + +test('the guest list without a connection asks for one', async () => { + saveConfig({ items: [], settings: { server: {} } }); + const r = await request('GET', '/api/proxmox/guests'); + assert.equal(r.status, 400); + assert.equal(r.body.code, 'invalid.proxmox-missing'); +}); diff --git a/api/test/system-summary-proxmox-data.test.js b/api/test/system-summary-proxmox-data.test.js new file mode 100644 index 00000000..e84d67ad --- /dev/null +++ b/api/test/system-summary-proxmox-data.test.js @@ -0,0 +1,168 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +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 { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); + +const GB = 1024 ** 3; +const TOKEN = 'stackyard@pve!dash'; +const SECRET = '0b5f7c2e-1d3a-4c6b-9e8f-2a7d4b1c0e9f'; + +const REPLIES = { + '/nodes': [ + { node: 'pve', status: 'online', uptime: 86400, cpu: 0.1 }, + { node: 'pve2', status: 'online', uptime: 3600, cpu: 0.2 }, + ], + '/nodes/pve/status': { + cpu: 0.125, + wait: 0.02, + uptime: 90000, + memory: { used: 8 * GB, total: 32 * GB, free: 24 * GB }, + rootfs: { used: 25 * GB, total: 100 * GB, free: 75 * GB, avail: 70 * GB }, + }, + '/nodes/pve/storage': [ + { storage: 'local-lvm', used: 300 * GB, total: 400 * GB }, + { storage: 'local', used: 10 * GB, total: 100 * GB }, + ], +}; + +function ctxFor(config, reply, endpoint = 'system') { + const sent = []; + const ctx = { + endpoint, + config: { + statProvider: 'proxmox', + proxmoxUrl: 'pve.local:8006', + proxmoxTokenId: TOKEN, + proxmoxSecret: SECRET, + proxmoxNode: 'pve', + ...config, + }, + normalizeBase, + serviceBase, + serviceLink, + fetchJSON: async (url, opts) => { + sent.push({ url, opts }); + if (reply) return reply(url); + const p = new URL(url).pathname.replace('/api2/json', ''); + return p in REPLIES ? { status: 200, data: { data: REPLIES[p] } } : { status: 404, data: null }; + }, + ...errorParts(), + }; + ctx.dispatchProvider = (handlers, opts) => dispatchProvider(ctx, handlers, opts); + return { ctx, sent }; +} + +const SLOTS = [{ type: 'cpu' }, { type: 'ram' }, { type: 'disk', primary: '', secondary: 'local-lvm' }]; + +test('a bare host is reached over HTTPS with the token header', async () => { + const { ctx, sent } = ctxFor({ slots: SLOTS }); + await dataFn(ctx); + assert.ok(sent.every(s => s.url.startsWith('https://pve.local:8006/api2/json/'))); + assert.equal(sent[0].opts.headers.Authorization, `PVEAPIToken=${TOKEN}=${SECRET}`); +}); + +test('the node status maps onto the shape the widget renders', async () => { + const { ctx } = ctxFor({ slots: SLOTS }); + const r = await dataFn(ctx); + assert.equal(r.cpu, 12.5); + assert.equal(r.ram, 25); + assert.equal(r.temp, null); + assert.equal(r.iowait, 2); + assert.equal(r.uptime, 90000); + assert.deepEqual(r.disks, [ + { mount: '/', usedPct: 25, totalGb: 100 }, + { mount: 'local-lvm', usedPct: 75, totalGb: 400 }, + ]); + assert.equal(r.link, 'https://pve.local:8006'); +}); + +test('a blank or / slot reads the root filesystem under the mount the widget looks up', async () => { + const widget = fs.readFileSync( + path.join(__dirname, '..', '..', 'ui', 'widgets', 'system-summary', 'index.html'), + 'utf8', + ); + assert.match(widget, /byMount\.get\(slot\.primary \|\| '\/'\)/); + for (const primary of ['', '/']) { + const { ctx } = ctxFor({ slots: [{ type: 'disk', primary }] }); + const byMount = new Map((await dataFn(ctx)).disks.map(d => [d.mount, d])); + assert.equal(byMount.get(primary || '/')?.usedPct, 25, JSON.stringify(primary)); + } +}); + +test('a chosen node is read with one request, or two with storage', async () => { + const { ctx, sent } = ctxFor({ slots: SLOTS }); + await dataFn(ctx); + assert.deepEqual(sent.map(x => new URL(x.url).pathname).sort(), [ + '/api2/json/nodes/pve/status', + '/api2/json/nodes/pve/storage', + ]); +}); + +test('a chosen node that is gone is named', async () => { + const { ctx } = ctxFor({ proxmoxNode: 'old', slots: SLOTS }); + await assert.rejects(() => dataFn(ctx), /no node called old/); +}); + +test('an address copied from the Proxmox tab drops the view after #', async () => { + const { ctx, sent } = ctxFor({ proxmoxUrl: 'https://pve.local:8006/#v1:0:18:4:::::::', slots: SLOTS }); + const r = await dataFn(ctx); + assert.ok(sent.every(x => x.url.startsWith('https://pve.local:8006/api2/json/'))); + assert.equal(r.link, 'https://pve.local:8006'); +}); + +test('storage is only asked for when a slot names one', async () => { + const { ctx, sent } = ctxFor({ slots: [{ type: 'cpu' }, { type: 'disk', primary: '/' }] }); + await dataFn(ctx); + assert.ok(!sent.some(s => s.url.includes('/storage'))); +}); + +test('the only node is used when none is chosen', async () => { + const one = url => { + const p = new URL(url).pathname.replace('/api2/json', ''); + const data = p === '/nodes' ? [REPLIES['/nodes'][0]] : REPLIES[p]; + return { status: 200, data: { data } }; + }; + const { ctx } = ctxFor({ proxmoxNode: '', slots: SLOTS }, one); + assert.equal((await dataFn(ctx)).cpu, 12.5); +}); + +test('a cluster with no node chosen asks for one', async () => { + const { ctx } = ctxFor({ proxmoxNode: '', slots: SLOTS }); + await assert.rejects(() => dataFn(ctx), /Choose a node/); +}); + +test('the node picker lists the nodes', async () => { + const { ctx } = ctxFor({}, null, 'nodes'); + assert.deepEqual((await dataFn(ctx)).options, [ + { value: 'pve', label: 'pve' }, + { value: 'pve2', label: 'pve2' }, + ]); +}); + +test('a refused token and a missing role are auth failures', async () => { + for (const status of [401, 403]) { + const { ctx } = ctxFor({ slots: SLOTS }, () => ({ status, data: null })); + await assert.rejects( + () => dataFn(ctx), + e => e.kind === 'auth', + ); + } +}); + +test('a token ID that could split the header is refused before any request', async () => { + const { ctx, sent } = ctxFor({ proxmoxTokenId: 'a@b!c\r\nX: y', slots: SLOTS }); + await assert.rejects(() => dataFn(ctx), /user@realm!name/); + assert.equal(sent.length, 0); +}); + +test('the self-signed switch reaches the request', async () => { + const { ctx, sent } = ctxFor({ proxmoxSkipTls: true, slots: SLOTS }); + await dataFn(ctx); + assert.ok(sent.every(s => s.opts.skipTls === true)); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 7875af11..dfa0ec75 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -84,14 +84,27 @@

Setting this to Errors logs warnings and errors only. Security events are always logged.

-
Docker Container Health Checks
-
+
Health Checks
+
Hide Healthy Badge
-
Socket URLhttp://socket-proxy:2375
-
+
+

Shows a badge on apps whose container, address or Proxmox guest is down.

+
+
+
+
Docker Socket URLhttp://socket-proxy:2375
+

Use the URL of a socket proxy container, never the Docker socket itself. A name such as http://socket-proxy:2375 works only when this container and the proxy share a Docker network; an IP address works only when the proxy publishes its port beyond the host's own loopback.

+
+
Proxmox Addresshttps://192.168.1.10:8006
+
Token IDstackyard@pve!dashboard
+
SecretNot set
+
Allow Self-Signed Certificates
+
+
+

For Proxmox checks. Use an API token with the PVEAuditor role.

Security
@@ -254,6 +267,9 @@

General

+ + + @@ -302,6 +318,6 @@

General

- + diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 105b4f41..118f959e 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -45,9 +45,8 @@ "allowedHostsTip": "Solange kein Passwort festgelegt ist, antwortet Stackyard nur unter IP-Adressen, lokalen Namen wie nas oder nas.local und den hier aufgeführten Adressen. Trenne sie mit Kommas.", "monitoring": "Überwachung", "loggingLevel": "Protokollstufe", - "dockerHealth": "Docker-Container-Zustandsprüfungen", "hideHealthy": "„In Ordnung“-Badge ausblenden", - "socketUrl": "Socket-URL", + "socketUrl": "Docker-Socket-URL", "loggingTip": "Bei „Fehler“ werden nur Warnungen und Fehler protokolliert. Sicherheitsereignisse werden immer protokolliert.", "socketTip": "Verwende die URL eines Socket-Proxy-Containers, niemals den Docker-Socket selbst. Ein Name wie http://socket-proxy:2375 funktioniert nur, wenn dieser Container und der Proxy dasselbe Docker-Netzwerk nutzen; eine IP-Adresse nur, wenn der Proxy seinen Port nicht nur auf dem Loopback des Hosts veröffentlicht.", "security": "Sicherheit", @@ -70,7 +69,15 @@ "showSettingsAria": "Einstellungen im Dashboard anzeigen", "dockNeedsShown": "Aktiviere „Einstellungen im Dashboard anzeigen“, um sie ins Dock zu legen.", "importJson": "Konfigurations-JSON importieren", - "importForeignFiles": "Homepage- oder Dashy-YAML-Konfiguration importieren" + "importForeignFiles": "Homepage- oder Dashy-YAML-Konfiguration importieren", + "healthChecks": "Zustandsprüfungen", + "healthChecksTip": "Zeigt ein Badge an Apps, deren Container, Adresse oder Proxmox-Gast nicht läuft.", + "proxmoxUrl": "Proxmox-Adresse", + "proxmoxTokenId": "Token-ID", + "proxmoxSecret": "Geheimer Schlüssel", + "proxmoxTip": "Für Proxmox-Prüfungen. Verwende ein API-Token mit der Rolle PVEAuditor. Ist die Privilegientrennung aktiv, gib die Rolle dem Token selbst.", + "proxmoxConnected_one": "Verbunden · {count} Gast", + "proxmoxConnected_other": "Verbunden · {count} Gäste" }, "appearance": { "appTitle": "App-Titel", @@ -179,7 +186,7 @@ "dockFull": "Dock voll ({max}/{max}). Entferne zuerst eine App.", "badge": "Badge", "healthCheck": "Zustandsprüfung", - "healthGlobalOff": "Für Container-Prüfungen muss unter „Allgemein“ die Option „Docker-Container-Zustandsprüfungen“ aktiviert sein. URL-Prüfungen funktionieren auch ohne.", + "healthGlobalOff": "Zustandsprüfungen sind aus. Schalte sie unter „Allgemein“ ein.", "type": "Typ", "container": "Container", "ping": "Ping", @@ -245,7 +252,12 @@ "needsApiPath": "Verbunden, aber diese Adresse hat keinen API-Pfad. Gib die vollständige API-URL des Dienstes ein.", "needsApiPathError": "Diese Adresse hat keinen API-Pfad. Gib die vollständige API-URL des Dienstes ein.", "foundValues_one": "{count} Wert gefunden", - "foundValues_other": "{count} Werte gefunden" + "foundValues_other": "{count} Werte gefunden", + "proxmox": "Proxmox", + "guest": "Gast", + "chooseGuest": "Gast auswählen", + "noGuests": "Keine VMs oder Container gefunden.", + "loadingGuests": "Gäste werden geladen…" }, "adminError": { "sessionExpired": "Deine Sitzung ist abgelaufen. Melde dich erneut an, um fortzufahren.", @@ -285,7 +297,12 @@ "selfSignedPublic": "Der Dienst verwendet ein selbstsigniertes Zertifikat. „Selbstsigniertes Zertifikat zulassen“ gilt nur für Adressen in deinem eigenen Netzwerk.", "tlsExpired": "Das Zertifikat des Dienstes ist abgelaufen.", "timedOut": "Der Dienst hat nicht rechtzeitig geantwortet.", - "demoOutbound": "In der Live-Demo sind Prüfungen deaktiviert." + "demoOutbound": "In der Live-Demo sind Prüfungen deaktiviert.", + "proxmoxToken": "Prüfe Token-ID und geheimen Schlüssel. Die Token-ID sieht so aus: user@realm!name.", + "proxmoxRefused": "Proxmox hat das Token abgelehnt. Prüfe Token-ID und geheimen Schlüssel.", + "proxmoxPermission": "Das Token darf das nicht lesen. Gib ihm die Rolle PVEAuditor.", + "notProxmox": "Dort hat etwas geantwortet, aber es ist kein Proxmox.", + "proxmoxMissing": "Gib unter „Allgemein“ bei den Zustandsprüfungen Proxmox-Adresse, Token-ID und geheimen Schlüssel ein." }, "home": { "search": "Suchen", @@ -364,7 +381,6 @@ "importUnknownFormat": "{file} ist keine gethomepage- oder Dashy-Konfiguration.", "importTooLarge": "{file} enthält zu viele Einträge für einen Import.", "secretsWithheld": "Zugangsdaten erneut eingeben für: {items}. Sie wurden nicht übernommen, weil sich die zugehörige Anfrage geändert hat.", - "socketUrlMissing": "Adresse des Socket-Proxys eingeben oder Docker-Überwachung ausschalten.", "socketUrlBad": "Socket-Proxy nicht gespeichert: {reason}", "socketUrlUnverified": "Gespeichert, aber der Socket-Proxy hat nicht geantwortet: {reason}", "socketHintSharedNetwork": "Diesen Namen löst Docker nur für Container in einem gemeinsamen Netzwerk auf. Hänge beide in dasselbe Netzwerk oder verwende eine Adresse, die der Proxy veröffentlicht.", @@ -422,7 +438,12 @@ "containerNotFound": "Container nicht gefunden", "containerState": "Container {state}", "pingFailed": "Ping fehlgeschlagen: {error}", - "pingReturned": "Ping ergab {status}" + "pingReturned": "Ping ergab {status}", + "guestNotFound": "Gast nicht gefunden", + "guestNoData": "Keine Daten von Proxmox", + "guestStopped": "Gast gestoppt", + "guestState": "Gast {state}", + "proxmoxFailed": "Proxmox: {error}" }, "setup": { "title": "Dashboard-Passwort festlegen?", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 6c677106..7d9c7742 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -45,9 +45,8 @@ "allowedHostsTip": "While no password is set, Stackyard answers only on IP addresses, local names such as nas or nas.local, and the addresses listed here. Separate them with commas.", "monitoring": "Monitoring", "loggingLevel": "Logging Level", - "dockerHealth": "Docker Container Health Checks", "hideHealthy": "Hide Healthy Badge", - "socketUrl": "Socket URL", + "socketUrl": "Docker Socket URL", "loggingTip": "Setting this to Errors logs warnings and errors only. Security events are always logged.", "socketTip": "Use the URL of a socket proxy container, never the Docker socket itself. A name such as http://socket-proxy:2375 works only when this container and the proxy share a Docker network; an IP address works only when the proxy publishes its port beyond the host’s own loopback.", "security": "Security", @@ -70,7 +69,15 @@ "showSettingsAria": "Show Settings on dashboard", "dockNeedsShown": "Turn on Show Settings on dashboard to put it in the dock.", "importJson": "Import configuration JSON", - "importForeignFiles": "Import Homepage or Dashy YAML config" + "importForeignFiles": "Import Homepage or Dashy YAML config", + "healthChecks": "Health Checks", + "healthChecksTip": "Shows a badge on apps whose container, address or Proxmox guest is down.", + "proxmoxUrl": "Proxmox Address", + "proxmoxTokenId": "Token ID", + "proxmoxSecret": "Secret", + "proxmoxTip": "For Proxmox checks. Use an API token with the PVEAuditor role. If Privilege Separation is on, give the role to the token itself.", + "proxmoxConnected_one": "Connected · {count} guest", + "proxmoxConnected_other": "Connected · {count} guests" }, "appearance": { "appTitle": "App Title", @@ -179,7 +186,7 @@ "dockFull": "Dock full ({max}/{max}). Remove an app first.", "badge": "Badge", "healthCheck": "Health Check", - "healthGlobalOff": "Container checks need Docker Container Health Checks turned on in General. URL checks work without it.", + "healthGlobalOff": "Health checks are off. Turn them on in General.", "type": "Type", "container": "Container", "ping": "Ping", @@ -245,7 +252,12 @@ "needsApiPath": "Connected, but this address has no API path. Enter the service's full API URL.", "needsApiPathError": "This address has no API path. Enter the service's full API URL.", "foundValues_one": "Found {count} value", - "foundValues_other": "Found {count} values" + "foundValues_other": "Found {count} values", + "proxmox": "Proxmox", + "guest": "Guest", + "chooseGuest": "Choose a guest", + "noGuests": "No VMs or containers found.", + "loadingGuests": "Loading guests…" }, "adminError": { "sessionExpired": "Your session has expired. Sign in again to continue.", @@ -285,7 +297,12 @@ "selfSignedPublic": "The service uses a self-signed certificate. Allowing a self-signed certificate only applies to addresses on your own network.", "tlsExpired": "The service's certificate has expired.", "timedOut": "The service did not answer in time.", - "demoOutbound": "Checks are turned off in the live demo." + "demoOutbound": "Checks are turned off in the live demo.", + "proxmoxToken": "Check the token ID and secret. The token ID looks like user@realm!name.", + "proxmoxRefused": "Proxmox refused the token. Check the token ID and secret.", + "proxmoxPermission": "The token cannot read this. Give it the PVEAuditor role.", + "notProxmox": "Something answered there, but it is not Proxmox.", + "proxmoxMissing": "Enter the Proxmox address, token ID and secret under Health Checks in General." }, "home": { "search": "Search", @@ -364,7 +381,6 @@ "importUnknownFormat": "{file} is not a gethomepage or Dashy config.", "importTooLarge": "{file} has too many entries to import.", "secretsWithheld": "Enter the credential again for: {items}. It was not kept, because the request it belongs to changed.", - "socketUrlMissing": "Enter the socket proxy address, or turn Docker monitoring off.", "socketUrlBad": "Socket proxy not saved: {reason}", "socketUrlUnverified": "Saved, but the socket proxy did not answer: {reason}", "socketHintSharedNetwork": "That name is resolved by Docker, which answers only for containers on a shared network. Put both on the same network, or use an address the proxy publishes.", @@ -422,7 +438,12 @@ "containerNotFound": "Container not found", "containerState": "Container {state}", "pingFailed": "Ping failed: {error}", - "pingReturned": "Ping returned {status}" + "pingReturned": "Ping returned {status}", + "guestNotFound": "Guest not found", + "guestNoData": "No data from Proxmox", + "guestStopped": "Guest stopped", + "guestState": "Guest {state}", + "proxmoxFailed": "Proxmox: {error}" }, "setup": { "title": "Set a dashboard password?", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 88941c9c..a46fd506 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -45,9 +45,8 @@ "allowedHostsTip": "Mientras no haya contraseña, Stackyard solo responde en direcciones IP, nombres locales como nas o nas.local, y las direcciones de esta lista. Sepáralas con comas.", "monitoring": "Monitorización", "loggingLevel": "Nivel de registro", - "dockerHealth": "Comprobaciones de estado de contenedores Docker", "hideHealthy": "Ocultar insignia de estado correcto", - "socketUrl": "URL del socket", + "socketUrl": "URL del socket de Docker", "loggingTip": "Con el nivel en Errores solo se registran advertencias y errores. Los eventos de seguridad siempre se registran.", "socketTip": "Usa la URL de un contenedor proxy de socket, nunca el socket de Docker. Un nombre como http://socket-proxy:2375 solo funciona si este contenedor y el proxy comparten una red de Docker; una dirección IP solo funciona si el proxy publica su puerto fuera de la interfaz de loopback del host.", "security": "Seguridad", @@ -70,7 +69,16 @@ "showSettingsAria": "Mostrar Ajustes en el panel", "dockNeedsShown": "Activa «Mostrar Ajustes en el panel» para añadir Ajustes al Dock.", "importJson": "Importar JSON de configuración", - "importForeignFiles": "Importar configuración YAML de Homepage o Dashy" + "importForeignFiles": "Importar configuración YAML de Homepage o Dashy", + "healthChecks": "Comprobaciones de estado", + "healthChecksTip": "Muestra una insignia en las apps cuyo contenedor, dirección o invitado de Proxmox no funciona.", + "proxmoxUrl": "Dirección de Proxmox", + "proxmoxTokenId": "ID del token", + "proxmoxSecret": "Secreto", + "proxmoxTip": "Para las comprobaciones de Proxmox. Usa un token de API con el rol PVEAuditor. Si la separación de privilegios está activada, asigna el rol al propio token.", + "proxmoxConnected_one": "Conectado · {count} invitado", + "proxmoxConnected_many": "Conectado · {count} invitados", + "proxmoxConnected_other": "Conectado · {count} invitados" }, "appearance": { "appTitle": "Título de la aplicación", @@ -181,7 +189,7 @@ "dockFull": "Dock lleno ({max}/{max}). Quita primero una aplicación del Dock.", "badge": "Insignia", "healthCheck": "Comprobación de estado", - "healthGlobalOff": "Las comprobaciones de contenedor requieren activar «Comprobaciones de estado de contenedores Docker» en General. Las comprobaciones de URL funcionan sin ello.", + "healthGlobalOff": "Las comprobaciones de estado están desactivadas. Actívalas en General.", "type": "Tipo", "container": "Contenedor", "ping": "Ping", @@ -250,7 +258,12 @@ "needsApiPathError": "Esta dirección no tiene ruta de API. Introduce la URL de API completa del servicio.", "foundValues_one": "{count} valor encontrado", "foundValues_many": "{count} de valores encontrados", - "foundValues_other": "{count} valores encontrados" + "foundValues_other": "{count} valores encontrados", + "proxmox": "Proxmox", + "guest": "Invitado", + "chooseGuest": "Elige un invitado", + "noGuests": "No se encontraron VM ni contenedores.", + "loadingGuests": "Cargando invitados…" }, "adminError": { "sessionExpired": "Tu sesión ha caducado. Vuelve a iniciar sesión para continuar.", @@ -290,7 +303,12 @@ "selfSignedPublic": "El servicio usa un certificado autofirmado. Permitir un certificado autofirmado solo se aplica a direcciones de tu propia red.", "tlsExpired": "El certificado del servicio ha caducado.", "timedOut": "El servicio no respondió a tiempo.", - "demoOutbound": "Las comprobaciones están desactivadas en la demo en vivo." + "demoOutbound": "Las comprobaciones están desactivadas en la demo en vivo.", + "proxmoxToken": "Revisa el ID del token y el secreto. El ID del token tiene la forma user@realm!name.", + "proxmoxRefused": "Proxmox rechazó el token. Revisa el ID del token y el secreto.", + "proxmoxPermission": "El token no puede leer esto. Dale el rol PVEAuditor.", + "notProxmox": "Algo respondió ahí, pero no es Proxmox.", + "proxmoxMissing": "Introduce la dirección de Proxmox, el ID del token y el secreto en Comprobaciones de estado, en General." }, "home": { "search": "Buscar", @@ -370,7 +388,6 @@ "importUnknownFormat": "{file} no es una configuración de gethomepage ni de Dashy.", "importTooLarge": "{file} tiene demasiadas entradas para importarlas.", "secretsWithheld": "Vuelve a introducir la credencial de: {items}. No se conservó porque cambió la solicitud a la que pertenece.", - "socketUrlMissing": "Introduce la dirección del proxy de socket o desactiva la monitorización de Docker.", "socketUrlBad": "Proxy de socket no guardado: {reason}", "socketUrlUnverified": "Guardado, pero el proxy de socket no respondió: {reason}", "socketHintSharedNetwork": "Ese nombre lo resuelve Docker, que solo responde para contenedores en una red compartida. Ponlos en la misma red o usa una dirección que el proxy publique.", @@ -431,7 +448,12 @@ "containerNotFound": "Contenedor no encontrado", "containerState": "Contenedor {state}", "pingFailed": "Ping fallido: {error}", - "pingReturned": "El ping devolvió {status}" + "pingReturned": "El ping devolvió {status}", + "guestNotFound": "Invitado no encontrado", + "guestNoData": "Sin datos de Proxmox", + "guestStopped": "Invitado detenido", + "guestState": "Invitado {state}", + "proxmoxFailed": "Proxmox: {error}" }, "setup": { "title": "¿Establecer una contraseña para el panel?", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index d303b5fa..503854e9 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -45,9 +45,8 @@ "allowedHostsTip": "تا وقتی رمز عبوری تعیین نشده، Stackyard فقط در آدرس‌های IP، نام‌های محلی مانند nas یا nas.local و آدرس‌های این فهرست پاسخ می‌دهد. آن‌ها را با ویرگول جدا کنید.", "monitoring": "نظارت", "loggingLevel": "سطح گزارش", - "dockerHealth": "بررسی سلامت کانتینرهای Docker", "hideHealthy": "پنهان کردن وضعیت سلامت", - "socketUrl": "آدرس سوکت", + "socketUrl": "آدرس سوکت Docker", "loggingTip": "با تنظیم روی «خطاها» تنها هشدارها و خطاها ثبت می‌شوند. رویدادهای امنیتی همیشه ثبت می‌شوند.", "socketTip": "از آدرس یک کانتینر پراکسی سوکت استفاده کنید، نه خود سوکت Docker. نامی مانند http://socket-proxy:2375 فقط زمانی کار می‌کند که این کانتینر و پراکسی در یک شبکه Docker باشند؛ آدرس IP فقط زمانی کار می‌کند که پراکسی درگاه خود را بیرون از لوپ‌بک میزبان منتشر کند.", "security": "امنیت", @@ -70,7 +69,15 @@ "showSettingsAria": "نمایش تنظیمات در داشبورد", "dockNeedsShown": "برای قرار دادن آن در داک، «نمایش تنظیمات در داشبورد» را روشن کنید.", "importJson": "وارد کردن JSON پیکربندی", - "importForeignFiles": "وارد کردن پیکربندی YAML مربوط به Homepage یا Dashy" + "importForeignFiles": "وارد کردن پیکربندی YAML مربوط به Homepage یا Dashy", + "healthChecks": "بررسی سلامت", + "healthChecksTip": "روی برنامه‌هایی که کانتینر، آدرس یا مهمان Proxmox آن‌ها از کار افتاده، نشان می‌گذارد.", + "proxmoxUrl": "آدرس Proxmox", + "proxmoxTokenId": "شناسه توکن", + "proxmoxSecret": "کلید محرمانه", + "proxmoxTip": "برای بررسی Proxmox. از یک توکن API با نقش PVEAuditor استفاده کنید. اگر جداسازی امتیازها روشن است، نقش را به خود توکن بدهید.", + "proxmoxConnected_one": "متصل · {count} مهمان", + "proxmoxConnected_other": "متصل · {count} مهمان" }, "appearance": { "appTitle": "عنوان برنامه", @@ -179,7 +186,7 @@ "dockFull": "داک پر است ({max}/{max}). ابتدا یک برنامه را حذف کنید.", "badge": "نشان", "healthCheck": "بررسی سلامت", - "healthGlobalOff": "برای بررسی کانتینر، «بررسی سلامت کانتینرهای Docker» باید در بخش «عمومی» روشن باشد. بررسی آدرس بدون آن هم کار می‌کند.", + "healthGlobalOff": "بررسی سلامت خاموش است. آن را در بخش «عمومی» روشن کنید.", "type": "نوع", "container": "کانتینر", "ping": "پینگ", @@ -245,7 +252,12 @@ "needsApiPath": "متصل شد، اما این آدرس مسیر API ندارد. آدرس API کامل سرویس را وارد کنید.", "needsApiPathError": "این آدرس مسیر API ندارد. آدرس API کامل سرویس را وارد کنید.", "foundValues_one": "{count} مقدار یافت شد", - "foundValues_other": "{count} مقدار یافت شد" + "foundValues_other": "{count} مقدار یافت شد", + "proxmox": "Proxmox", + "guest": "مهمان", + "chooseGuest": "یک مهمان انتخاب کنید", + "noGuests": "هیچ ماشین مجازی یا کانتینری پیدا نشد.", + "loadingGuests": "در حال بارگذاری مهمان‌ها…" }, "adminError": { "sessionExpired": "نشست شما منقضی شده است. برای ادامه دوباره وارد شوید.", @@ -285,7 +297,12 @@ "selfSignedPublic": "این سرویس از گواهی خودامضا استفاده می‌کند. پذیرش گواهی خودامضا فقط برای آدرس‌های شبکه خودتان کاربرد دارد.", "tlsExpired": "گواهی این سرویس منقضی شده است.", "timedOut": "سرویس به‌موقع پاسخ نداد.", - "demoOutbound": "بررسی‌ها در نسخه نمایشی آنلاین غیرفعال‌اند." + "demoOutbound": "بررسی‌ها در نسخه نمایشی آنلاین غیرفعال‌اند.", + "proxmoxToken": "شناسه توکن و کلید محرمانه را بررسی کنید. شناسه توکن به شکل user@realm!name است.", + "proxmoxRefused": "Proxmox توکن را رد کرد. شناسه توکن و کلید محرمانه را بررسی کنید.", + "proxmoxPermission": "این توکن اجازه خواندن این را ندارد. نقش PVEAuditor را به آن بدهید.", + "notProxmox": "چیزی در آن آدرس پاسخ داد، اما Proxmox نیست.", + "proxmoxMissing": "آدرس Proxmox، شناسه توکن و کلید محرمانه را در بخش «عمومی»، زیر بررسی سلامت، وارد کنید." }, "home": { "search": "جستجو", @@ -364,7 +381,6 @@ "importUnknownFormat": "فایل {file} پیکربندی gethomepage یا Dashy نیست.", "importTooLarge": "فایل {file} موارد زیادی برای وارد کردن دارد.", "secretsWithheld": "اعتبارنامه این موارد را دوباره وارد کنید: {items}. چون درخواست مربوط به آن تغییر کرده، نگه داشته نشد.", - "socketUrlMissing": "آدرس پراکسی سوکت را وارد کنید یا نظارت Docker را خاموش کنید.", "socketUrlBad": "پراکسی سوکت ذخیره نشد: {reason}", "socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}", "socketHintSharedNetwork": "این نام را Docker فقط برای کانتینرهایی پاسخ می‌دهد که در یک شبکه مشترک هستند. هر دو را در یک شبکه بگذارید یا از آدرسی استفاده کنید که پراکسی منتشر می‌کند.", @@ -422,7 +438,12 @@ "containerNotFound": "کانتینر پیدا نشد", "containerState": "کانتینر {state}", "pingFailed": "پینگ ناموفق: {error}", - "pingReturned": "پینگ {status} برگرداند" + "pingReturned": "پینگ {status} برگرداند", + "guestNotFound": "مهمان پیدا نشد", + "guestNoData": "داده‌ای از Proxmox نرسید", + "guestStopped": "مهمان متوقف شده", + "guestState": "مهمان {state}", + "proxmoxFailed": "Proxmox: {error}" }, "setup": { "title": "رمز عبوری برای داشبورد تنظیم شود؟", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 4bd18c0b..86e0faa5 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -45,9 +45,8 @@ "allowedHostsTip": "Tant qu’aucun mot de passe n’est défini, Stackyard ne répond qu’aux adresses IP, aux noms locaux comme nas ou nas.local, et aux adresses listées ici. Séparez-les par des virgules.", "monitoring": "Surveillance", "loggingLevel": "Niveau de journalisation", - "dockerHealth": "Vérifications de l’état des conteneurs Docker", "hideHealthy": "Masquer le badge d’état sain", - "socketUrl": "URL du socket", + "socketUrl": "URL du socket Docker", "loggingTip": "Avec le niveau Erreurs, seuls les avertissements et les erreurs sont journalisés. Les événements de sécurité le sont toujours.", "socketTip": "Utilisez l’URL d’un conteneur proxy de socket, jamais le socket Docker lui-même. Un nom tel que http://socket-proxy:2375 ne fonctionne que si ce conteneur et le proxy partagent un réseau Docker ; une adresse IP ne fonctionne que si le proxy publie son port au-delà de la boucle locale de l’hôte.", "security": "Sécurité", @@ -70,7 +69,16 @@ "showSettingsAria": "Afficher les Réglages sur le tableau de bord", "dockNeedsShown": "Activez « Afficher les Réglages sur le tableau de bord » pour les placer dans le Dock.", "importJson": "Importer le JSON de configuration", - "importForeignFiles": "Importer une configuration YAML Homepage ou Dashy" + "importForeignFiles": "Importer une configuration YAML Homepage ou Dashy", + "healthChecks": "Vérifications de l’état", + "healthChecksTip": "Affiche un badge sur les apps dont le conteneur, l’adresse ou l’invité Proxmox ne répond pas.", + "proxmoxUrl": "Adresse Proxmox", + "proxmoxTokenId": "ID du jeton", + "proxmoxSecret": "Secret", + "proxmoxTip": "Pour les vérifications Proxmox. Utilisez un jeton d’API avec le rôle PVEAuditor. Si la séparation des privilèges est activée, attribuez le rôle au jeton lui-même.", + "proxmoxConnected_one": "Connecté · {count} invité", + "proxmoxConnected_many": "Connecté · {count} invités", + "proxmoxConnected_other": "Connecté · {count} invités" }, "appearance": { "appTitle": "Titre de l’application", @@ -181,7 +189,7 @@ "dockFull": "Dock plein ({max}/{max}). Retirez d’abord une application.", "badge": "Badge", "healthCheck": "Vérification de l’état", - "healthGlobalOff": "Les vérifications de conteneur nécessitent d’activer « Vérifications de l’état des conteneurs Docker » dans Général. Les vérifications d’URL fonctionnent sans.", + "healthGlobalOff": "Les vérifications de l’état sont désactivées. Activez-les dans Général.", "type": "Type", "container": "Conteneur", "ping": "Ping", @@ -250,7 +258,12 @@ "needsApiPathError": "Cette adresse n’a pas de chemin d’API. Saisissez l’URL d’API complète du service.", "foundValues_one": "{count} valeur trouvée", "foundValues_many": "{count} de valeurs trouvées", - "foundValues_other": "{count} valeurs trouvées" + "foundValues_other": "{count} valeurs trouvées", + "proxmox": "Proxmox", + "guest": "Invité", + "chooseGuest": "Choisir un invité", + "noGuests": "Aucune VM ni aucun conteneur trouvé.", + "loadingGuests": "Chargement des invités…" }, "adminError": { "sessionExpired": "Votre session a expiré. Reconnectez-vous pour continuer.", @@ -290,7 +303,12 @@ "selfSignedPublic": "Le service utilise un certificat auto-signé. Autoriser un certificat auto-signé ne vaut que pour les adresses de votre propre réseau.", "tlsExpired": "Le certificat du service a expiré.", "timedOut": "Le service n’a pas répondu à temps.", - "demoOutbound": "Les vérifications sont désactivées dans la démo en ligne." + "demoOutbound": "Les vérifications sont désactivées dans la démo en ligne.", + "proxmoxToken": "Vérifiez l’ID du jeton et le secret. L’ID du jeton a la forme user@realm!name.", + "proxmoxRefused": "Proxmox a refusé le jeton. Vérifiez l’ID du jeton et le secret.", + "proxmoxPermission": "Le jeton ne peut pas lire ceci. Donnez-lui le rôle PVEAuditor.", + "notProxmox": "Quelque chose a répondu, mais ce n’est pas Proxmox.", + "proxmoxMissing": "Saisissez l’adresse Proxmox, l’ID du jeton et le secret dans Vérifications de l’état, sous Général." }, "home": { "search": "Rechercher", @@ -370,7 +388,6 @@ "importUnknownFormat": "{file} n’est pas une configuration gethomepage ou Dashy.", "importTooLarge": "{file} contient trop d’entrées pour être importé.", "secretsWithheld": "Saisissez à nouveau les identifiants pour : {items}. Ils n’ont pas été conservés, car la requête associée a changé.", - "socketUrlMissing": "Saisissez l’adresse du proxy de socket ou désactivez la surveillance Docker.", "socketUrlBad": "Proxy de socket non enregistré : {reason}", "socketUrlUnverified": "Enregistré, mais le proxy de socket n’a pas répondu : {reason}", "socketHintSharedNetwork": "Ce nom est résolu par Docker, qui ne répond que pour des conteneurs partageant un réseau. Placez les deux sur le même réseau, ou utilisez une adresse publiée par le proxy.", @@ -431,7 +448,12 @@ "containerNotFound": "Conteneur introuvable", "containerState": "Conteneur {state}", "pingFailed": "Échec du ping : {error}", - "pingReturned": "Le ping a renvoyé {status}" + "pingReturned": "Le ping a renvoyé {status}", + "guestNotFound": "Invité introuvable", + "guestNoData": "Aucune donnée de Proxmox", + "guestStopped": "Invité arrêté", + "guestState": "Invité {state}", + "proxmoxFailed": "Proxmox : {error}" }, "setup": { "title": "Définir un mot de passe pour le tableau de bord ?", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 5a052c1b..fc085057 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -45,9 +45,8 @@ "allowedHostsTip": "在未设置密码时,Stackyard 只在 IP 地址、nas 或 nas.local 这样的本地名称以及此处列出的地址上响应。多个地址用逗号分隔。", "monitoring": "监控", "loggingLevel": "日志级别", - "dockerHealth": "Docker 容器健康检查", "hideHealthy": "隐藏健康徽章", - "socketUrl": "套接字代理地址", + "socketUrl": "Docker 套接字代理地址", "loggingTip": "设为“错误”时只记录警告和错误。安全事件始终记录。", "socketTip": "请使用套接字代理容器的地址,而不是 Docker 套接字本身。像 http://socket-proxy:2375 这样的名称只有在本容器与代理位于同一个 Docker 网络时才有效;IP 地址只有在代理将端口发布到主机回环之外时才有效。", "security": "安全", @@ -70,7 +69,14 @@ "showSettingsAria": "在仪表板上显示设置", "dockNeedsShown": "开启“在仪表板上显示设置”后,才能将其放入程序坞。", "importJson": "导入配置 JSON", - "importForeignFiles": "导入 Homepage 或 Dashy 的 YAML 配置" + "importForeignFiles": "导入 Homepage 或 Dashy 的 YAML 配置", + "healthChecks": "健康检查", + "healthChecksTip": "当应用的容器、地址或 Proxmox 虚拟机停止运行时,在其上显示徽章。", + "proxmoxUrl": "Proxmox 地址", + "proxmoxTokenId": "令牌 ID", + "proxmoxSecret": "密钥", + "proxmoxTip": "用于 Proxmox 检查。请使用具有 PVEAuditor 角色的 API 令牌。如果开启了特权分离,请将该角色直接授予令牌。", + "proxmoxConnected_other": "已连接 · {count} 台虚拟机" }, "appearance": { "appTitle": "应用标题", @@ -177,7 +183,7 @@ "dockFull": "程序坞已满({max}/{max})。请先从程序坞移除一个应用。", "badge": "徽章", "healthCheck": "健康检查", - "healthGlobalOff": "容器检查需要在“通用”中开启“Docker 容器健康检查”。网址检查无需开启即可使用。", + "healthGlobalOff": "健康检查已关闭。请在“通用”中开启。", "type": "类型", "container": "容器", "ping": "Ping", @@ -240,7 +246,12 @@ "connectedNoValues": "已连接,未找到数值", "needsApiPath": "已连接,但该地址没有 API 路径。请输入服务的完整 API 地址。", "needsApiPathError": "该地址没有 API 路径。请输入服务的完整 API 地址。", - "foundValues_other": "找到 {count} 个数值" + "foundValues_other": "找到 {count} 个数值", + "proxmox": "Proxmox", + "guest": "虚拟机", + "chooseGuest": "选择虚拟机", + "noGuests": "未找到虚拟机或容器。", + "loadingGuests": "正在加载虚拟机…" }, "adminError": { "sessionExpired": "会话已过期,请重新登录。", @@ -280,7 +291,12 @@ "selfSignedPublic": "该服务使用自签名证书。允许自签名证书仅适用于你自己网络中的地址。", "tlsExpired": "该服务的证书已过期。", "timedOut": "服务未及时响应。", - "demoOutbound": "在线演示中已关闭检查功能。" + "demoOutbound": "在线演示中已关闭检查功能。", + "proxmoxToken": "请检查令牌 ID 和密钥。令牌 ID 的格式为 user@realm!name。", + "proxmoxRefused": "Proxmox 拒绝了该令牌。请检查令牌 ID 和密钥。", + "proxmoxPermission": "该令牌无权读取此内容。请为其分配 PVEAuditor 角色。", + "notProxmox": "该地址有响应,但不是 Proxmox。", + "proxmoxMissing": "请在“通用”的健康检查中填写 Proxmox 地址、令牌 ID 和密钥。" }, "home": { "search": "搜索", @@ -358,7 +374,6 @@ "importUnknownFormat": "{file} 不是 gethomepage 或 Dashy 配置。", "importTooLarge": "{file} 的条目太多,无法导入。", "secretsWithheld": "请重新输入以下项的凭据:{items}。未保留凭据,因为它所属的请求已更改。", - "socketUrlMissing": "请输入套接字代理地址,或关闭 Docker 监控。", "socketUrlBad": "未保存套接字代理:{reason}", "socketUrlUnverified": "已保存,但套接字代理没有响应:{reason}", "socketHintSharedNetwork": "该名称由 Docker 解析,且只对处于同一网络的容器生效。请将两者放入同一网络,或改用代理已发布的地址。", @@ -413,7 +428,12 @@ "containerNotFound": "未找到容器", "containerState": "容器 {state}", "pingFailed": "Ping 失败:{error}", - "pingReturned": "Ping 返回 {status}" + "pingReturned": "Ping 返回 {status}", + "guestNotFound": "未找到虚拟机", + "guestNoData": "未收到 Proxmox 数据", + "guestStopped": "虚拟机已停止", + "guestState": "虚拟机 {state}", + "proxmoxFailed": "Proxmox:{error}" }, "setup": { "title": "要设置仪表板密码吗?", diff --git a/ui/index.html b/ui/index.html index b797aa34..6c381d9f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@
- + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 30d91642..c8ec63de 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -8,7 +8,7 @@ import { splitIconRef, throughServer, } from '/js/icons.js?v=5ac01d72'; -import { state } from '/js/admin-state.js?v=af772a1b'; +import { state } from '/js/admin-state.js?v=064b9b85'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { @@ -33,11 +33,11 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=7b3a1dac'; +} from '/js/admin-shared.js?v=c28805e1'; import { createListbox } from '/js/listbox.js?v=95e9b354'; -import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=4cfe9a7b'; -import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; +import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=2ba67671'; +import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; @@ -125,7 +125,7 @@ export function buildSettingsTileForm(body, item) { export function buildAppForm(body, item) { const dockBlocked = isDockBlocked(state.items, item, DOCK_MAX); - const globalHealthOn = !!inpById('srv-docker-en')?.checked; + const globalHealthOn = inpById('srv-hc-en')?.checked !== false; const mon = item?.monitoring || {}; const hc = mon.healthcheck || { enabled: !!(item?.container || item?.ping), @@ -139,7 +139,7 @@ export function buildAppForm(body, item) { }; const staticBadge = mon.staticBadge || {}; const hasStatic = !!staticBadge.enabled; - const isPing = !!hc.pingUrl; + const hcType = hc.proxmoxGuest ? 'pve' : hc.pingUrl ? 'ping' : 'con'; const skipTls = !!item?.skipTlsVerify; const login = act.login?.username ? act.login : null; const hasAuth = !!(act.params?.length || act.headers?.length || login); @@ -185,11 +185,16 @@ export function buildAppForm(body, item) {
${t('app.healthCheck')}${tog('hc-en', hc.enabled, t('app.healthCheck'))}
${t('app.type')}
- - + + +
-
${ier('ie-hc-con', t('app.container'), 'hc-con', hc.container, t('app.containerPh'))}
-
+
${ier('ie-hc-con', t('app.container'), 'hc-con', hc.container, t('app.containerPh'))}
+
+
${t('app.guest')}
+
+
+
${ier('ie-hc-ping', t('app.pingUrl'), 'hc-ping', hc.pingUrl, t('app.pingUrlPh'), 'url')}
@@ -306,13 +311,18 @@ export function buildAppForm(body, item) { hcEn?.addEventListener('change', e => { showHide('hc-sub', tgt(e).checked); }); + const guests = wireGuestPicker(item?.monitoring?.healthcheck?.proxmoxGuest); + if (inpById('hc-type-pve')?.checked) guests.load(); document.querySelectorAll('input[name="hc-type"]').forEach(r => r.addEventListener('change', () => { - const ping = inpById('hc-type-ping')?.checked; + const ping = !!inpById('hc-type-ping')?.checked; + const pve = !!inpById('hc-type-pve')?.checked; swapContent(el('hc-con-row')?.parentElement, () => { - showHide('hc-con-row', !ping); + showHide('hc-con-row', !ping && !pve); showHide('hc-ping-row', ping); + showHide('hc-pve-row', pve); }); + if (pve) guests.load(); }), ); el('hc-ping-test')?.addEventListener('click', testPing); @@ -938,6 +948,42 @@ function updPrev(explicit) { p.replaceChildren(img); } +/** @param {unknown} saved */ +function wireGuestPicker(saved) { + const slot = el('hc-pve-slot'); + const st = el('hc-pve-status'); + const current = Number.isInteger(Number(saved)) && Number(saved) > 0 ? String(saved) : ''; + const fallback = current ? [{ value: current, label: current }] : []; + const box = createListbox({ + id: 'hc-pve', + label: t('app.guest'), + options: fallback, + value: current, + placeholder: t('app.chooseGuest'), + emptyText: t('app.noGuests'), + }); + state._pveGuest = box; + slot?.appendChild(box.el); + let loaded = false; + return { + async load() { + if (loaded) return; + loaded = true; + if (st) st.textContent = t('app.loadingGuests'); + try { + const r = await apiGet('/api/proxmox/guests'); + const options = (r.guests || []).map(g => ({ value: String(g.vmid), label: `${g.name} (${g.vmid})` })); + if (current && !options.some(o => o.value === current)) options.unshift(...fallback); + box.setOptions(options, box.getValue() || current); + if (st) st.textContent = ''; + } catch (e) { + loaded = false; + if (st) st.textContent = '✗ ' + errorText(e); + } + }, + }; +} + async function testPing() { const url = inpById('hc-ping')?.value?.trim(); const st = el('hc-ping-status'); diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 105824ee..7b5e39a7 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,6 +1,6 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=7b3a1dac'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=c28805e1'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; -import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; +import { loginErrorKey } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { el, inp as inpById, qa } from '/js/utils.js?v=705ca896'; import { blockingScreenFor } from '/js/config-recovery.js?v=85138697'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 5de3f93d..5d6031d6 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,4 +1,4 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=7b3a1dac'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=c28805e1'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=705ca896'; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index 0eb3edc2..ee4ba356 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -11,8 +11,8 @@ import { applyDrop, canDropInGroup, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=1d1d553e'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; -import { state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=2de65cd5'; +import { state } from '/js/admin-state.js?v=064b9b85'; +import { snapshotItems } from '/js/admin-save-logic.js?v=32cb6aeb'; import { qa } from '/js/utils.js?v=705ca896'; /** @type {(before: unknown) => void} */ diff --git a/ui/js/admin-error.js b/ui/js/admin-error.js index 72c8c903..5140bcfc 100644 --- a/ui/js/admin-error.js +++ b/ui/js/admin-error.js @@ -47,6 +47,11 @@ const BY_CODE = Object.freeze({ 'network.tls-expired': 'adminError.tlsExpired', 'timeout.no-answer': 'adminError.timedOut', 'blocked.demo': 'adminError.demoOutbound', + 'invalid.proxmox-token': 'adminError.proxmoxToken', + 'invalid.proxmox-missing': 'adminError.proxmoxMissing', + 'upstream.proxmox-refused': 'adminError.proxmoxRefused', + 'upstream.proxmox-permission': 'adminError.proxmoxPermission', + 'upstream.not-proxmox': 'adminError.notProxmox', }); const BY_KIND = Object.freeze({ diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 0445f8a4..35772fd8 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -11,18 +11,18 @@ The filter and the collapsed folders are in admin-state.js, because the page reads and writes them too. */ -import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { snapshotItems } from '/js/admin-save-logic.js?v=2de65cd5'; +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; +import { snapshotItems } from '/js/admin-save-logic.js?v=32cb6aeb'; import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=b3befae4'; import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; -import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=e18422ae'; +import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=2f953e63'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=7b3a1dac'; +import { paintIcon, toast } from '/js/admin-shared.js?v=c28805e1'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=28393455'; +import { sizeLabel } from '/js/admin-widget-form.js?v=6728c3b0'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-save-logic.js b/ui/js/admin-save-logic.js index ba139e71..f8c2fdd7 100644 --- a/ui/js/admin-save-logic.js +++ b/ui/js/admin-save-logic.js @@ -285,7 +285,12 @@ export function buildAppItem(v, orig, takenIds = []) { dock: v.dock || false, skipTlsVerify: v.skipTlsVerify || undefined, monitoring: { - healthcheck: { enabled: v.hcEn && (!!v.hcCon || !!v.hcPing), container: v.hcCon, pingUrl: v.hcPing }, + healthcheck: { + enabled: v.hcEn && (!!v.hcCon || !!v.hcPing || !!v.hcPve), + container: v.hcCon, + pingUrl: v.hcPing, + proxmoxGuest: v.hcPve || undefined, + }, activity: { enabled: v.actEn && !!v.actUrl, url: v.actUrl, diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index c1e3c3ff..c95ab2d6 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,5 +1,5 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=7b3a1dac'; -import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=c28805e1'; +import { socketProbeAdvice } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; import { @@ -13,8 +13,8 @@ import { import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { serialWrites } from '/js/admin-save-logic.js?v=2de65cd5'; -import { renderColorControl } from '/js/admin-color-control.js?v=4cfe9a7b'; +import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; +import { renderColorControl } from '/js/admin-color-control.js?v=2ba67671'; import { BACKDROP } from '/js/background.js?v=25428bf2'; import { ALLOWED_HOSTS_MAX, @@ -34,6 +34,7 @@ let _srvTrack = null; let _bgTrack = null; let _savedWallpaperUrl = ''; let _unsplashKeySet = false; +let _pveSecretSet = false; /** The wallpaper link the server holds, for undoing a link that failed. */ export const savedWallpaperUrl = () => _savedWallpaperUrl; @@ -55,9 +56,13 @@ const _shown = id => { const readServerForm = () => JSON.stringify([ _val('srv-ip'), - _val('srv-docker-en'), + _val('srv-hc-en'), _val('srv-socket'), _val('srv-hide-healthy'), + _val('srv-pve-url'), + _val('srv-pve-token'), + _val('srv-pve-secret'), + _val('srv-pve-tls'), _val('srv-hosts'), _val('log-level'), _val('lang-sel'), @@ -241,6 +246,9 @@ export function loadSettings(c) { }; _sv('ie-ip-v', s.server?.hostIp, '192.168.1.100'); _sv('ie-socket-v', s.server?.socketProxyUrl, 'http://socket-proxy:2375'); + _sv('ie-pve-url-v', s.server?.proxmox?.url, 'https://192.168.1.10:8006'); + _sv('ie-pve-token-v', s.server?.proxmox?.tokenId, 'stackyard@pve!dashboard'); + showPveSecretState(!!s.server?.proxmox?.secretSet); _sv('ie-hosts-v', (s.server?.allowedHosts || []).join(', '), t('general.allowedHostsNone')); _sv('ie-pw-v', '', t('common.notSet')); /* set below after auth check */ const _si = (id, v) => { @@ -249,6 +257,10 @@ export function loadSettings(c) { }; _si('srv-ip', s.server?.hostIp || ''); _si('srv-socket', s.server?.socketProxyUrl || ''); + _si('srv-pve-url', s.server?.proxmox?.url || ''); + _si('srv-pve-token', s.server?.proxmox?.tokenId || ''); + const pveTls = inp('srv-pve-tls'); + if (pveTls) pveTls.checked = s.server?.proxmox?.skipTlsVerify === true; _si('srv-hosts', (s.server?.allowedHosts || []).join(', ')); _sv('ie-bgcol-v', s.background?.collection, t('appearance.collectionId')); _si('bg-col-inp', s.background?.collection || ''); @@ -260,19 +272,12 @@ export function loadSettings(c) { const ipEl = inp('srv-ip'); if (ipEl) ipEl.value = s.server?.hostIp || ''; - const dockerEnEl = inp('srv-docker-en'); - const dockerSubEl = el('srv-docker-sub'); + const hcEnEl = inp('srv-hc-en'); const socketEl = inp('srv-socket'); const hideHealthyEl = inp('srv-hide-healthy'); - if (dockerEnEl) { - dockerEnEl.checked = !!s.server?.socketProxyUrl; - const applyDocker = (v, now = false) => { - if (dockerSubEl) dockerSubEl.classList.toggle('open', v); - reveal(el('srv-docker-rows'), v, now); - reveal(el('socket-hint-wrap'), v, now); - }; - applyDocker(dockerEnEl.checked, true); - dockerEnEl.addEventListener('change', () => applyDocker(dockerEnEl.checked)); + if (hcEnEl) { + hcEnEl.checked = s.server?.healthChecks !== false; + applyHealthChecks(hcEnEl.checked, true); } if (hideHealthyEl) hideHealthyEl.checked = s.server?.hideHealthyBadge !== false; if (socketEl) socketEl.value = s.server?.socketProxyUrl || ''; @@ -349,6 +354,68 @@ export function showWallpaperFile(url) { } } +export function applyHealthChecks(on, now = false) { + reveal(el('srv-hc-rows'), on, now); + reveal(el('hc-sources-wrap'), on, now); + reveal(el('hc-off-wrap'), !on, now); +} + +const readProxmoxForm = () => ({ + url: inp('srv-pve-url')?.value?.trim() || '', + tokenId: inp('srv-pve-token')?.value?.trim() || '', + secret: inp('srv-pve-secret')?.value?.trim() || undefined, + skipTlsVerify: inp('srv-pve-tls')?.checked === true, +}); + +const pveSecretLabel = () => t(_pveSecretSet ? 'common.configured' : 'common.notSet'); + +function showPveSecretState(set) { + _pveSecretSet = set; + const vEl = el('ie-pve-secret-v'); + if (vEl) { + vEl.textContent = pveSecretLabel(); + vEl.classList.toggle('is-ph', !set); + } +} + +function clearPveSecretInput() { + const node = inp('srv-pve-secret'); + if (node) node.value = ''; + const vEl = el('ie-pve-secret-v'); + if (vEl) vEl.textContent = pveSecretLabel(); +} + +/** The secret opens empty. A blank secret keeps the stored one. */ +export function wireProxmox() { + const row = el('ie-pve-secret'); + const secretInp = inp('srv-pve-secret'); + if (row && secretInp) { + wireInlineEdit(row, secretInp, { + type: 'password', + placeholder: () => t(_pveSecretSet ? 'widgetCfg.replaceSecret' : 'common.notSet'), + fill: false, + render: (valEl, v) => { + valEl.textContent = v ? t('widgetCfg.newValueSet') : pveSecretLabel(); + }, + }); + } + const btn = /** @type {HTMLButtonElement | null} */ (el('pve-test')); + const st = el('pve-status'); + btn?.addEventListener('click', async () => { + if (!st) return; + btn.disabled = true; + st.textContent = t('app.testing'); + try { + const r = await apiPost('/api/proxmox/test', readProxmoxForm()); + st.textContent = r.ok ? '✓ ' + t('general.proxmoxConnected', { count: r.guests }) : '✗ ' + errorText(r); + } catch (e) { + st.textContent = '✗ ' + errorText(e); + } finally { + btn.disabled = false; + } + }); +} + const unsplashKeyLabel = () => t(_unsplashKeySet ? 'common.configured' : 'common.notSet'); function showUnsplashKeyState(set) { @@ -507,12 +574,9 @@ async function saveServer() { /* Asked with the other refusals, so a wrong address never reaches the config. */ - if (inp('srv-docker-en')?.checked) { - const url = inp('srv-socket')?.value?.trim() || ''; - if (!url) { - toast(t('toast.socketUrlMissing'), 'err'); - return; - } + const socketUrlTyped = inp('srv-socket')?.value?.trim() || ''; + if (inp('srv-hc-en')?.checked && socketUrlTyped) { + const url = socketUrlTyped; let probe; try { probe = await apiPost('/api/docker/test', { url }); @@ -574,13 +638,13 @@ async function saveServer() { const c = await apiGet('/api/config'); c.settings = c.settings || {}; const prevLang = c.settings.language || 'en'; - const dockerEnabled = inp('srv-docker-en')?.checked || false; - const socketUrl = inp('srv-socket')?.value?.trim() || ''; c.settings.server = { ...c.settings.server, hostIp: inp('srv-ip')?.value?.trim() || '', - socketProxyUrl: dockerEnabled ? socketUrl : '', + healthChecks: inp('srv-hc-en')?.checked !== false, + socketProxyUrl: socketUrlTyped, hideHealthyBadge: inp('srv-hide-healthy')?.checked !== false, + proxmox: readProxmoxForm(), allowedHosts, }; c.settings.logLevel = inp('log-level')?.value || 'info'; @@ -596,7 +660,10 @@ async function saveServer() { pwEl.value = ''; } } + clearPveSecretInput(); _srvTrack?.reset(); + const after = await apiGet('/api/config').catch(() => null); + if (after) showPveSecretState(!!after.settings?.server?.proxmox?.secretSet); toast(socketWarning || t('toast.saved'), socketWarning ? 'err' : 'ok'); if (langChanged) { location.reload(); diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index cfa51037..b78834bb 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -2,7 +2,7 @@ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=b3befae4'; import { el, isolate, q } from '/js/utils.js?v=705ca896'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { errorAdvice } from '/js/admin-error.js?v=b0881957'; +import { errorAdvice } from '/js/admin-error.js?v=a60b17ac'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { iconChain } from '/js/icons.js?v=5ac01d72'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-state.js b/ui/js/admin-state.js index 51755235..e53f05f0 100644 --- a/ui/js/admin-state.js +++ b/ui/js/admin-state.js @@ -21,6 +21,7 @@ export const state = { _evItem: null, _evIsEdit: false, _folderApps: null, + _pveGuest: null, _wtype: 'custom', _wsize: 'medium', diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index f9ac2080..4f254279 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,7 +1,7 @@ -import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=7b3a1dac'; +import { state } from '/js/admin-state.js?v=064b9b85'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=c28805e1'; import { createListbox } from '/js/listbox.js?v=95e9b354'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=19857d5b'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c37e3419'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 8c077ac4..f48f87a5 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=0888b695'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9082bfd5'; +} from '/js/admin-app-form.js?v=ee3bdd9a'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=8138c219'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=95504ae8'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=1ae9a956'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -21,7 +21,7 @@ import { serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=2de65cd5'; +} from '/js/admin-save-logic.js?v=32cb6aeb'; import { loadSettings, savedWallpaperUrl, @@ -29,7 +29,9 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=e1d92487'; + wireProxmox, + applyHealthChecks, +} from '/js/admin-settings.js?v=6f79a26b'; import { apiGet, apiPost, @@ -43,9 +45,9 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=7b3a1dac'; -import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=28393455'; +} from '/js/admin-shared.js?v=c28805e1'; +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=6728c3b0'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; @@ -63,7 +65,7 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=fee60cb7'; +} from '/js/import-foreign.js?v=3582433f'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { @@ -696,13 +698,15 @@ async function doSave(orig) { }; } else { const isPing = inp('hc-type-ping')?.checked; + const isPve = inp('hc-type-pve')?.checked; captureActLabels(); const v = { label: inp('f-lbl')?.value?.trim(), href: inp('f-href')?.value?.trim(), hcEn: inp('hc-en')?.checked, - hcCon: isPing ? '' : inp('hc-con')?.value?.trim() || '', + hcCon: isPing || isPve ? '' : inp('hc-con')?.value?.trim() || '', hcPing: isPing ? inp('hc-ping')?.value?.trim() || '' : '', + hcPve: isPve ? Number(state._pveGuest?.getValue()) || 0 : 0, skipTlsVerify: inp('f-skip-tls')?.checked || false, actEn: inp('act-en')?.checked, actUrl: inp('f-burl')?.value?.trim() || '', @@ -777,6 +781,8 @@ function initNav() { function initAllInlineEdits() { initInlineEdit('ie-ip', 'srv-ip', { placeholder: '192.168.1.100' }); initInlineEdit('ie-socket', 'srv-socket', { placeholder: 'http://socket-proxy:2375' }); + initInlineEdit('ie-pve-url', 'srv-pve-url', { type: 'url', placeholder: 'https://192.168.1.10:8006' }); + initInlineEdit('ie-pve-token', 'srv-pve-token', { placeholder: 'stackyard@pve!dashboard' }); initInlineEdit('ie-hosts', 'srv-hosts', { placeholder: () => t('general.allowedHostsNone') }); initInlineEdit('ie-pw', 'sec-pw', { @@ -805,6 +811,7 @@ function initAllInlineEdits() { } wireUnsplashKey(); + wireProxmox(); const colInp = document.createElement('input'); colInp.id = 'bg-col-inp'; @@ -889,15 +896,11 @@ function initSecToggle() { en.addEventListener('change', () => apply(en.checked)); } -function initDockerToggle() { - const en = inp('srv-docker-en'); +function initHealthChecksToggle() { + const en = inp('srv-hc-en'); if (!en) return; - function apply(on, now = false) { - reveal(el('srv-docker-rows'), on, now); - reveal(el('socket-hint-wrap'), on, now); - } - apply(en.checked, true); - en.addEventListener('change', () => apply(en.checked)); + applyHealthChecks(en.checked, true); + en.addEventListener('change', () => applyHealthChecks(en.checked)); } function initBgType() { @@ -1406,7 +1409,7 @@ initList({ openModal, openFolderPicker, save: saveOrRevert }); initNav(); initAllInlineEdits(); initSecToggle(); -initDockerToggle(); +initHealthChecksToggle(); initBgType(); initBgFit(); initWallpaperUpload(); diff --git a/ui/js/badge-logic.js b/ui/js/badge-logic.js index db76a348..6f412d98 100644 --- a/ui/js/badge-logic.js +++ b/ui/js/badge-logic.js @@ -21,6 +21,29 @@ const EN = { 'status.containerDead': 'Container failed', 'status.pingFailed': 'Ping failed: {error}', 'status.pingReturned': 'Ping returned {status}', + 'status.guestNotFound': 'Guest not found', + 'status.guestNoData': 'No data from Proxmox', + 'status.guestStopped': 'Guest stopped', + 'status.guestState': 'Guest {state}', + 'status.proxmoxFailed': 'Proxmox: {error}', + 'adminError.proxmoxToken': 'Check the token ID and secret. The token ID looks like user@realm!name.', + 'adminError.proxmoxRefused': 'Proxmox refused the token. Check the token ID and secret.', + 'adminError.proxmoxPermission': 'The token cannot read this. Give it the PVEAuditor role.', + 'adminError.notProxmox': 'Something answered there, but it is not Proxmox.', + 'adminError.proxmoxMissing': 'Enter the Proxmox address, token ID and secret under Health Checks in General.', + 'adminError.timedOut': 'The service did not answer in time.', +}; + +const GUEST_STATES = { + stopped: 'status.guestStopped', +}; + +const PROXMOX_ERRORS = { + 'invalid.proxmox-token': 'adminError.proxmoxToken', + 'invalid.proxmox-missing': 'adminError.proxmoxMissing', + 'upstream.proxmox-refused': 'adminError.proxmoxRefused', + 'upstream.proxmox-permission': 'adminError.proxmoxPermission', + 'upstream.not-proxmox': 'adminError.notProxmox', }; const CONTAINER_STATES = { @@ -124,6 +147,20 @@ export function healthReason(detail, translate) { parts.push(_clip(tr('status.pingFailed', { error: _clip(detail.pingError, room) }), REASON_MAX)); } else if (detail.pingStatus >= 400) parts.push(tr('status.pingReturned', { status: detail.pingStatus })); + const pveKey = PROXMOX_ERRORS[detail.proxmoxCode]; + if (pveKey) parts.push(_clip(tr(pveKey), REASON_MAX)); + else if (detail.proxmoxError) { + const error = detail.proxmoxCode === 'timeout.no-answer' ? tr('adminError.timedOut') : detail.proxmoxError; + const around = tr('status.proxmoxFailed', { error: '' }).length; + const room = Math.max(8, REASON_MAX - around); + parts.push(_clip(tr('status.proxmoxFailed', { error: _clip(error, room) }), REASON_MAX)); + } else if (detail.guestState === 'missing') parts.push(tr('status.guestNotFound')); + else if (detail.guestState === 'unknown') parts.push(tr('status.guestNoData')); + else if (typeof detail.guestState === 'string' && detail.guestState !== 'running') { + const known = GUEST_STATES[detail.guestState]; + parts.push(_clip(known ? tr(known) : tr('status.guestState', { state: detail.guestState }), REASON_MAX)); + } + return parts.join(' \u2022 '); } diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 5c1b102b..dee6027b 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -35,7 +35,7 @@ import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=389e0ed0'; -import { setupErrorKey } from '/js/admin-error.js?v=b0881957'; +import { setupErrorKey } from '/js/admin-error.js?v=a60b17ac'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { @@ -47,7 +47,7 @@ import { resetMobileChrome, mkFolderGlyph, } from '/js/ui.js?v=c66ab377'; -import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=5c72bd4c'; +import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=85b1bc96'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; import { observeGlass } from '/js/glass-rim.js?v=3faec233'; @@ -178,7 +178,8 @@ function bupd(id) { const hideHealthy = S.server?.hideHealthyBadge !== false; const custom = item?.monitoring?.activity?.custom || {}; const staticBdg = item?.monitoring?.staticBadge || {}; - const hasHC = !!(item?.monitoring?.healthcheck?.enabled || item?.container || item?.ping); + const hasHC = + S.server?.healthChecks !== false && !!(item?.monitoring?.healthcheck?.enabled || item?.container || item?.ping); const act = item?.monitoring?.activity || {}; const isFolder = item?.type === 'folder'; diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index 47c40dbc..eefa7c75 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -3,7 +3,7 @@ only: a widget in the source becomes a plain app tile, never a Stackyard widget. Keep this module free of the DOM and of the network. */ -import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=2de65cd5'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=32cb6aeb'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 369d8b1f..af2f9b68 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=7b3a1dac'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=c28805e1'; import { createListbox } from '/js/listbox.js?v=95e9b354'; -import { renderColorControl } from '/js/admin-color-control.js?v=4cfe9a7b'; +import { renderColorControl } from '/js/admin-color-control.js?v=2ba67671'; import { seedCarried, applyOptionSet, @@ -14,7 +14,7 @@ import { groupBounds, visibleFieldFlags, } from '/js/admin-logic.js?v=b3befae4'; -import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; +import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; import { qi } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { SAVED_ROW } from '/js/saved-row.js?v=44039df6'; diff --git a/ui/test/admin-nav.test.mjs b/ui/test/admin-nav.test.mjs index 98d2185d..634587dc 100644 --- a/ui/test/admin-nav.test.mjs +++ b/ui/test/admin-nav.test.mjs @@ -40,6 +40,13 @@ test('each header Save is driven by its own unsaved-change check', () => { assert.match(fs.readFileSync(new URL('../js/admin.js', import.meta.url), 'utf8'), /addEventListener\('beforeunload'/); }); +test('a saved Proxmox secret leaves General with nothing unsaved', () => { + const src = fs.readFileSync(new URL('../js/admin-settings.js', import.meta.url), 'utf8'); + const save = src.slice(src.indexOf('async function saveServer')); + assert.ok(save.indexOf('clearPveSecretInput();') >= 0); + assert.ok(save.indexOf('clearPveSecretInput();') < save.indexOf('_srvTrack?.reset();')); +}); + /* An import writes the config itself. Without this the page warns about unsaved changes that are already on the server. */ test('an import records what it saved', () => { @@ -118,16 +125,16 @@ test('a stored section from an older version does not blank the page', () => { /* ── A group footer explains the row above it ─────────────────────────────── */ -/* The logging tip sat after four rows and described the first, with the Docker - health toggle between them. A footer that is not adjacent to its row reads as +/* The logging tip sat after four rows and described the first, with the health + checks toggle between them. A footer that is not adjacent to its row reads as describing whatever it follows. */ test('the logging tip follows the logging row, not the group below it', () => { const level = html.indexOf('id="log-level"'); const tip = html.indexOf('data-i18n="general.loggingTip"'); - const docker = html.indexOf('id="rl-srv-docker-en"'); - assert.ok(level > -1 && tip > -1 && docker > -1, 'the Monitoring group has moved'); + const health = html.indexOf('id="rl-srv-hc-en"'); + assert.ok(level > -1 && tip > -1 && health > -1, 'the Monitoring group has moved'); assert.ok(tip > level, 'the tip comes before the row it explains'); - assert.ok(tip < docker, 'a row it does not describe sits between them'); + assert.ok(tip < health, 'a row it does not describe sits between them'); }); /* "Errors shows warnings and errors only" used the option name as a bare diff --git a/ui/test/admin-reveal.test.mjs b/ui/test/admin-reveal.test.mjs index 678bde2f..8232f4dc 100644 --- a/ui/test/admin-reveal.test.mjs +++ b/ui/test/admin-reveal.test.mjs @@ -70,7 +70,8 @@ test('the instant path suppresses the transition and then releases it', () => { test('state read back from the server opens instantly', () => { for (const call of [ "reveal(el('ie-pw-wrap'), !!d.enabled, true)", - 'applyDocker(dockerEnEl.checked, true)', + 'applyHealthChecks(hcEnEl.checked, true)', + 'applyHealthChecks(en.checked, true)', 'apply(en.checked, true)', ]) { assert.ok(js.includes(call), `${call} is gone, so the page animates its own sections open after it paints`); diff --git a/ui/test/admin-save-logic.test.mjs b/ui/test/admin-save-logic.test.mjs index 6051e2c2..d64f636d 100644 --- a/ui/test/admin-save-logic.test.mjs +++ b/ui/test/admin-save-logic.test.mjs @@ -48,6 +48,23 @@ test('buildAppItem builds a minimal app with disabled monitoring', () => { assert.match(item.id, /^My_App_/); }); +test('buildAppItem saves a Proxmox check as the guest id alone', () => { + const { item } = buildAppItem( + { label: 'HA', href: 'http://x', hcEn: true, hcCon: '', hcPing: '', hcPve: 100, scol: 'dark', spaths: [] }, + null, + ); + assert.deepEqual(item.monitoring.healthcheck, { enabled: true, container: '', pingUrl: '', proxmoxGuest: 100 }); +}); + +test('buildAppItem leaves a check off when no guest is chosen', () => { + const { item } = buildAppItem( + { label: 'HA', href: 'http://x', hcEn: true, hcCon: '', hcPing: '', hcPve: 0, scol: 'dark', spaths: [] }, + null, + ); + assert.equal(item.monitoring.healthcheck.enabled, false); + assert.equal(item.monitoring.healthcheck.proxmoxGuest, undefined); +}); + test('buildAppItem preserves an existing id and defaults color to dark', () => { const { item } = buildAppItem({ label: 'X', href: 'http://x', scol: '', spaths: [] }, { id: 'keep_me' }); assert.equal(item.id, 'keep_me'); diff --git a/ui/test/badge-logic.test.mjs b/ui/test/badge-logic.test.mjs index c1091ef3..b4136bab 100644 --- a/ui/test/badge-logic.test.mjs +++ b/ui/test/badge-logic.test.mjs @@ -222,6 +222,40 @@ test('a container running but failing its own healthcheck is reported', () => { assert.equal(healthReason({ state: 'running', status: 'Up 3 days (unhealthy)' }), 'Up 3 days (unhealthy)'); }); +test('a stopped Proxmox guest says so', () => { + assert.equal(healthReason({ guestState: 'stopped' }), 'Guest stopped'); +}); + +test('a Proxmox guest that is not listed is not found', () => { + assert.equal(healthReason({ guestState: 'missing' }), 'Guest not found'); +}); + +test('a listed guest Proxmox has no figures for is not called missing', () => { + assert.equal(healthReason({ guestState: 'unknown' }), 'No data from Proxmox'); +}); + +test('an unfamiliar guest state is named as Proxmox sent it', () => { + assert.equal(healthReason({ guestState: 'suspended' }), 'Guest suspended'); +}); + +test('a Proxmox failure carries its reason, and a running guest none', () => { + assert.equal(healthReason({ proxmoxError: 'connect ECONNREFUSED' }), 'Proxmox: connect ECONNREFUSED'); + assert.equal(healthReason({ guestState: 'running' }), ''); +}); + +test("a Proxmox failure with a code is said in the reader's language", () => { + const tr = (k, v) => `[${k}${v ? ` ${JSON.stringify(v)}` : ''}]`; + const refused = { proxmoxError: 'Proxmox refused the token.', proxmoxCode: 'upstream.proxmox-refused' }; + assert.equal(healthReason(refused, tr), '[adminError.proxmoxRefused]'); + assert.equal(healthReason(refused), 'Proxmox refused the token. Check the token ID and secret.'); + const missing = { proxmoxError: 'Proxmox is not set up in Settings.', proxmoxCode: 'invalid.proxmox-missing' }; + assert.equal(healthReason(missing, tr), '[adminError.proxmoxMissing]'); + const late = { proxmoxError: 'Proxmox did not answer in time.', proxmoxCode: 'timeout.no-answer' }; + assert.equal(healthReason(late), 'Proxmox: The service did not answer in time.'); + const status = { proxmoxError: 'Proxmox answered with HTTP 500.', proxmoxCode: 'upstream.status' }; + assert.equal(healthReason(status), 'Proxmox: Proxmox answered with HTTP 500.'); +}); + test('a healthy container produces no reason', () => { assert.equal(healthReason({ state: 'running', status: 'Up 3 days' }), ''); }); diff --git a/ui/test/control-names.test.mjs b/ui/test/control-names.test.mjs index fc233ac6..59138084 100644 --- a/ui/test/control-names.test.mjs +++ b/ui/test/control-names.test.mjs @@ -28,7 +28,7 @@ const { uniqueTitle } = await import('../js/widget-types.js'); const TOGGLES = [...admin.matchAll(/