diff --git a/api/test/widget-error.test.js b/api/test/widget-error.test.js index 6794464c..fa8e2aa9 100644 --- a/api/test/widget-error.test.js +++ b/api/test/widget-error.test.js @@ -205,3 +205,25 @@ test('no widget reports a user-facing failure with a plain Error', () => { `Use ctx.fail(message, { kind }) so the message reaches the browser:\n${offenders.join('\n')}`, ); }); + +/* One definition of a normalised upstream base URL. + + The stats widget carried three spellings of it: a local diskBase helper and + two inline copies, one of which stripped a single trailing slash where + ctx.normalizeBase strips several and also trims. Which one a URL met decided + whether "http://nas:8080/ " reached the service. + + connections keeps its own normBase, which is a different rule rather than a + copy of this one: it leaves a trailing slash in place, and one of its three + call sites strips slashes itself because that endpoint needs it. */ +const OWN_BASE_RULE = new Set(['connections']); + +test('no widget rebuilds the base-URL normalisation ctx already provides', () => { + const offenders = []; + for (const [name, p] of dataFiles) { + if (OWN_BASE_RULE.has(name)) continue; + const src = fs.readFileSync(p, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + if (/includes\(':\/\/'\)\s*\?/.test(src)) offenders.push(`${name}: hand-rolled scheme check`); + } + assert.deepEqual(offenders, [], `Use ctx.normalizeBase:\n${offenders.join('\n')}`); +}); diff --git a/ui/widgets/stats/data.js b/ui/widgets/stats/data.js index 498b488c..128407b9 100644 --- a/ui/widgets/stats/data.js +++ b/ui/widgets/stats/data.js @@ -11,8 +11,6 @@ module.exports = async function (ctx) { return systemSummary(ctx); }; -const diskBase = u => (u.includes('://') ? u : `http://${u}`).replace(/\/$/, ''); - /* Config-time picker: one option per drive or pool, shared by every bay row. */ function diskDevices(ctx) { return ctx.dispatchProvider( @@ -25,9 +23,9 @@ function diskDevices(ctx) { } async function scrutinyDeviceOptions(ctx) { - const { config, fetchJSON } = ctx; + const { config, fetchJSON, normalizeBase } = ctx; if (!config.scrutinyUrl) ctx.fail('Enter the Scrutiny URL first.', { kind: ctx.KIND.INVALID }); - const r = await fetchJSON(diskBase(config.scrutinyUrl) + '/api/summary', { timeout: 8000 }); + const r = await fetchJSON(normalizeBase(config.scrutinyUrl) + '/api/summary', { timeout: 8000 }); if (r.status >= 400) ctx.fail('Scrutiny HTTP ' + r.status); const summary = r.data?.data?.summary || {}; const options = Object.values(summary) @@ -48,10 +46,10 @@ function truenasStatus(ctx, r) { } async function truenasPoolOptions(ctx) { - const { config, fetchJSON } = ctx; + const { config, fetchJSON, normalizeBase } = ctx; if (!config.truenasUrl) ctx.fail('Enter the TrueNAS URL first.', { kind: ctx.KIND.INVALID }); if (!config.truenasKey) ctx.fail('Enter the TrueNAS API key first.', { kind: ctx.KIND.INVALID }); - const r = await fetchJSON(diskBase(config.truenasUrl) + '/api/v2.0/pool', { + const r = await fetchJSON(normalizeBase(config.truenasUrl) + '/api/v2.0/pool', { headers: { Authorization: 'Bearer ' + config.truenasKey }, timeout: 8000, }); @@ -109,12 +107,12 @@ async function systemSummary({ config, settings, metrics }) { /* Disk Health (Scrutiny): maps the widget's configured bays (device_id per bay) onto Scrutiny's SMART summary. */ async function diskHealthScrutiny(ctx) { - const { config, fetchJSON } = ctx; + const { config, fetchJSON, normalizeBase } = ctx; const url = config.scrutinyUrl; if (!url) ctx.fail('scrutinyUrl not configured', { kind: ctx.KIND.INVALID }); const bays = config.bays || []; - const base = url.includes('://') ? url.replace(/\/$/, '') : `http://${url.replace(/\/$/, '')}`; + const base = normalizeBase(url); const r = await fetchJSON(base + '/api/summary', { timeout: 8000 }); const summary = r.data?.data?.summary || {}; @@ -145,14 +143,14 @@ async function diskHealthScrutiny(ctx) { matched from /api/v2.0/pool. A pool's `healthy` flag is the per-bay status (healthy → 0, unhealthy → 2, the same codes the widget uses for Scrutiny). */ async function diskHealthTrueNas(ctx) { - const { config, fetchJSON } = ctx; + const { config, fetchJSON, normalizeBase } = ctx; const url = config.truenasUrl; const key = config.truenasKey; if (!url) ctx.fail('truenasUrl not configured', { kind: ctx.KIND.INVALID }); if (!key) ctx.fail('TrueNAS API key not configured', { kind: ctx.KIND.INVALID }); const bays = config.bays || []; - const base = url.includes('://') ? url.replace(/\/$/, '') : `http://${url.replace(/\/$/, '')}`; + const base = normalizeBase(url); const r = await fetchJSON(base + '/api/v2.0/pool', { headers: { Authorization: 'Bearer ' + key }, timeout: 8000,