// HTTP backend Overview widgets: offline simulated status feed, no Rust execution. import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; const names = ['backend_health', 'backend_activity', 'backend_errors', 'backend_latency', 'backend_traffic', 'backend_protocols', 'backend_requests', 'backend_capacity', 'backend_http5xx', 'backend_timeouts', 'backend_network', 'backend_retries', 'backend_queue', 'backend_rotations']; const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8')); for (const name of names) assert(registry.some(widget => widget.id === name && widget.default_hidden && widget.data_source === 'backend_status')); const source = fs.readFileSync('src/admin/web/app/backend-dashboard.js', 'utf8'); assert(!/\b(fetch|api)\s*\(/.test(source), 'Overview must not fetch per-widget metrics'); const cards = new Map(names.map(name => [name, { hidden: true }])); const nodes = new Map(); const get = id => { if (!nodes.has(id)) nodes.set(id, { textContent: '-', style: {}, attrs: {}, children: [], setAttribute(k,v) { this.attrs[k] = v; } }); return nodes.get(id); }; for (const type of ['h1', 'auto', 'compat', 'h2']) { const node = get('backend-mode-' + type); node.parentElement = get('backend-mode-bar'); node.parentElement.children.push(node); } let now = 1000; const window = { rustProxyPreferences: { current: () => null } }; const ctx = { window, ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: names }, document: { querySelector(selector) { return cards.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null; } }, $: get, t: key => key, fmt: (value, precision = 0) => Number(value).toFixed(precision), bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now }, }; vm.createContext(ctx); vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx); vm.runInContext(source, ctx); const module = window.rustProxyBackendDashboard; const data = { revision: 1, backend_pools: [{ id: 'pool1', members: [ { id: 'alpha', url: 'https://example.invalid', protocol: 'auto', status: 'up', active: 2, max_concurrent_requests: 4, capacity_rejections_total: 1, requests: 10, errors: 1, bytes_in: 1000, bytes_out: 2000, health_mode: 'tcp', last_probe_ms: 1234, latency_ms: 10, http_5xx_total: 2, timeouts_total: 1, connect_errors_total: 1, dns_errors_total: 1, tls_errors_total: 1, io_errors_total: 1, retry_attempts_total: 2, retry_successes_total: 1, queue_timeouts_total: 1, upstream_client_rotations_total: 2, upstream_client_generation: 3, upstream_client_generation_age_seconds: 500 }, { id: 'beta', url: 'http://example.invalid', protocol: 'http2', status: 'down', active: 0, requests: 5, errors: 2, bytes_in: 100, bytes_out: 400, health_mode: 'http', last_probe_ms: 0, latency_ms: 0 }, { id: 'gamma', url: 'http://example.invalid', protocol: 'http1', status: 'checking', active: 1, max_concurrent_requests: 2, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'tcp', last_probe_ms: 0, latency_ms: 0 }, { id: 'delta', url: 'http://example.invalid', status: 'draining', active: 0, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'off', last_probe_ms: 0, latency_ms: 0 }, ] }], }; const text = id => get(`backend-dashboard-${id}`).textContent; module.render(data); // Hidden by default: should not start collecting samples. assert(names.every(name => cards.get(name).hidden)); assert.equal(text('health'), '-'); module.render(data, []); assert(names.every(name => !cards.get(name).hidden)); assert.equal(text('health'), '1 / 3'); assert.match(text('health-detail'), /backendWidgetDown: 1/); assert.equal(text('activity'), '3'); assert.equal(text('errors'), '3'); assert.equal(text('latency'), '10.0 ms'); assert.equal(text('traffic'), '0 B/s'); assert.equal(text('protocols'), '4'); assert.match(text('protocols-detail'), /H1 1.*Auto 1.*backendWidgetModeCompat 1.*backendWidgetModeStrict 0.*backendWidgetH2c: 1.*backendWidgetModeUnknown: 1/); assert.equal(get('backend-mode-compat').style.flexGrow, '1'); assert.equal(get('backend-mode-h2').style.flexGrow, '0'); assert.equal(text('requests'), '0.0/s'); assert.match(text('requests-detail'), /backendWidgetRequestsTotal: 15/); assert.equal(text('capacity'), '50.0%'); assert.match(text('capacity-detail'), /3\/6.*backendWidgetCapacityRejects: 1/); assert.equal(text('http5xx'), '2'); assert.equal(text('timeouts'), '1'); assert.equal(text('network'), '4'); assert.equal(text('retries'), '2'); assert.equal(text('queue'), '2'); assert.equal(text('rotations'), '2'); assert.match(text('rotations-detail'), /backendWidgetPoolGeneration: 3.*backendWidgetPoolAge: 500s/); now = 3000; const next = structuredClone(data); next.backend_pools[0].members[0].requests = 30; next.backend_pools[0].members[0].errors = 3; next.backend_pools[0].members[0].bytes_in = 3000; next.backend_pools[0].members[0].bytes_out = 3000; next.backend_pools[0].members[0].active = 5; next.backend_pools[0].members[0].latency_ms = 22; next.backend_pools[0].members[0].http_5xx_total = 4; next.backend_pools[0].members[0].timeouts_total = 5; next.backend_pools[0].members[0].connect_errors_total = 3; next.backend_pools[0].members[0].retry_attempts_total = 4; next.backend_pools[0].members[0].retry_successes_total = 2; next.backend_pools[0].members[0].queue_timeouts_total = 3; // New member starts with huge absolute counters: must NOT appear as a spike. next.backend_pools[0].members.push({ id: 'new', url:'https://new.invalid', protocol: 'http1', status:'up', requests: 100000, errors: 1000, bytes_in: 10000000, bytes_out: 99999999, active: 0, health_mode: 'off', last_probe_ms: 0 }); module.render(next, []); assert.equal(text('errors'), '1005'); assert.match(text('errors-detail'), /1\.0\/s/); assert.match(text('errors-detail'), /10\.0%/); assert.equal(text('traffic'), '1500 B/s'); assert.equal(get('backend-dashboard-in-rate').textContent, '1000 B/s'); assert.equal(get('backend-dashboard-out-rate').textContent, '500 B/s'); assert.equal(text('activity'), '6'); assert.equal(text('requests'), '10.0/s'); assert.equal(text('capacity'), '100.0%'); assert.equal(text('latency'), '22.0 ms'); assert.match(text('http5xx-detail'), /1\.0\/s/); assert.match(text('timeouts-detail'), /2\.0\/s/); assert.match(text('network-detail'), /1\.0\/s/); assert.match(text('retries-detail'), /1\.0\/s.*backendWidgetRetriesSuccess: 2/); assert.match(text('queue-detail'), /1\.0\/s.*backendWidgetQueueRejected: 1/); // Repaint on toggles/translation must not produce additional rate samples. now = 3500; module.render(next, [], false); assert.match(text('errors-detail'), /1\.0\/s/); // Reset of a member's counters should never be treated as an error burst. now = 5000; const restarted = structuredClone(next); restarted.backend_pools[0].members[0].requests = 2; restarted.backend_pools[0].members[0].errors = 0; restarted.backend_pools[0].members[0].bytes_in = 999999; module.render(restarted, []); assert.match(text('errors-detail'), /0\.0\/s/); assert.equal(text('traffic'), '0 B/s'); assert.equal(text('requests'), '0.0/s'); // Reconfiguration invalidates prior generations, first sample is a baseline. now = 8000; restarted.revision = 2; module.render(restarted, []); assert.match(text('errors-detail'), /0\.0\/s/); // Missing origin data must show neutral state and not previously cached values. module.render({ revision: 3, backend_pools: [] }, []); assert.equal(text('health'), '-'); assert.equal(text('traffic'), '-'); assert.equal(text('protocols-detail'), 'backendWidgetUnavailable'); assert.equal(text('requests'), '-'); assert.equal(text('capacity'), '-'); for (const name of ['http5xx', 'timeouts', 'network', 'retries', 'queue', 'rotations']) assert.equal(text(name), '-', `neutral empty state for ${name}`); module.render(data, [...names]); assert(names.every(name => cards.get(name).hidden)); // Origin protocol editor must preserve legacy HTTPS fallback, expose strict // H2 as a separate new option, and distinguish cleartext h2c. const protocolHelp = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8'); const element = () => ({ dataset: {}, textContent: '', hidden: false }); const originCtx = { el: element, t: key => key }; vm.createContext(originCtx); vm.runInContext(protocolHelp, originCtx); const mounted = []; const panel = { append(...items) { mounted.push(...items); } }; const select = { value: 'http2', events: {}, addEventListener(type, handler) { this.events[type] = handler; } }; const url = { value: 'https://origin.example', events: {}, addEventListener(type, handler) { this.events[type] = handler; } }; originCtx.attachOriginProtocolDetails(panel, select, url); assert.equal(mounted.length, 2); assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpsDetails'); assert.equal(mounted[1].hidden, false, 'Saved HTTPS H2 needs a compatibility warning'); select.value = 'auto'; select.events.change(); assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpsDetails'); assert.equal(mounted[1].hidden, true); url.value = 'http://origin.example'; url.events.input(); assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpDetails'); select.value = 'http2'; select.events.change(); assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpDetails'); assert.equal(mounted[1].hidden, true); url.value = 'https://origin.example'; url.events.input(); assert.equal(mounted[1].hidden, false); select.value = 'h2'; select.events.change(); assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpsDetails'); assert.equal(mounted[1].hidden, true); url.value = 'http://origin.example'; url.events.input(); assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpDetails'); // Strict H2 is tracked independently from the backwards-compatible value. const strict = structuredClone(data); strict.backend_pools[0].members[2].protocol = 'h2'; module.render(strict, [], false); assert.match(text('protocols-detail'), /backendWidgetModeCompat 1.*backendWidgetModeStrict 1.*backendWidgetH2c: 2/); assert.equal(get('backend-mode-h2').style.flexGrow, '1'); console.log('PASS: 14 opt-in HTTP backend cards, ALPN mode distinction, capacity, request rates, fallback UI');