diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d47a7f..7ddb562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ## 0.33.3 +- Avoid one-point holes in full-width Overview counter-rate histories and + WWW rate charts after tab suspension, BFCache resume or overdue refresh. + Preserve already plotted history, skip unavailable rate samples and display + "-" until the next valid interval; genuine measured 0/s remains visible. + The Web Server service-rate sparkline no longer draws a false zero dip. + Add deterministic JavaScript and simulated Chromium regression coverage. + - Make main sidebar navigation highlight depend on the Appearance **interface theme** rather than Chart style. Selected/hover backgrounds reuse theme nav tokens; active text and the selection marker use readable theme colors. diff --git a/README.md b/README.md index 1103257..415ce24 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,11 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos Overview/protocols, optional wide/small charts, backend and Stream/Vhost traffic charts. After tab hide/pagehide (including BFCache) or a delayed refresh, keep plotted history, use the first reading as a new baseline, - and resume rate points on the next normal interval. A genuine measured - zero still appears as 0/s. Cache and Transform intervals and the separate + and resume rate points on the next normal interval. Full-width Overview, + WWW and Web Server charts keep their drawn lines continuous by not appending + the unavailable first rate sample; the current rate displays "-" until a + valid interval is measured. A genuine measured zero still appears as 0/s. + Cache and Transform intervals and the separate System information interval remain configurable; no additional polling. - **Development fixes :** keep Network diagnostics diff --git a/scripts/tests/dashboard_resume_rate_charts_ui_test.mjs b/scripts/tests/dashboard_resume_rate_charts_ui_test.mjs new file mode 100644 index 0000000..fe5e83e --- /dev/null +++ b/scripts/tests/dashboard_resume_rate_charts_ui_test.mjs @@ -0,0 +1,114 @@ +// Simulated DOM/status regression: no holes or artificial zeros after tab resume. +// This intentionally does not exercise the running Rust server or real browser. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8'); +const html = code('index.html'); + +class Element { + constructor() { this.attributes = new Map(); this.style = {}; this.textContent = ''; this.children = []; this.hidden = false; } + getAttribute(key) { return this.attributes.get(key) ?? null; } + setAttribute(key, value) { this.attributes.set(key, String(value)); } + removeAttribute(key) { this.attributes.delete(key); } + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this.children = children; } +} +const ids = new Map([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()])); +const widgets = new Map([...html.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(([, id]) => [id, new Element()])); +const listeners = new Map(); +const document = { + hidden: false, + addEventListener: (type, handler) => listeners.set(type, handler), + querySelector: selector => widgets.get(selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/)?.[1]) || null, + createElement: () => new Element(), +}; +const samples = { + getLimit: () => 60, + trim: values => { if (values.length > 60) values.splice(0, values.length - 60); }, + onChange: () => {}, +}; +const window = { + addEventListener() {}, rustProxyOverviewSamples: samples, + rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) }, +}; +let now = 1000; +const context = vm.createContext({ + window, document, performance: { now: () => now }, currentPanel: 'overview', latestStatus: null, + $: id => ids.get(id) || null, fmt: (value, n = 0) => Number(value).toFixed(n), + fmtBytes: value => `${value} B`, t: value => value, +}); +for (const file of ['app/rate-sampling.js', 'app/overview-wide-metrics.js', 'app/web-overview.js']) + vm.runInContext(code(file), context, { filename: file }); + +const web = { + requests: 200, status_2xx: 190, status_3xx: 2, status_4xx: 5, status_5xx: 3, + static_requests: 100, php_requests: 80, other_requests: 20, + methods: [160, 20, 10, 5, 5], selected_status: [0, 0, 0, 0, 0, 0, 0, 0], + response_headers_us: 300000, response_bytes: 1200000, active_headers: 2, +}; +const php = { + metrics: { requests: 80, response_headers_us: 100000, response_bytes: 320000, failures: Array(9).fill(0) }, + capacity_limit: 6, capacity_used: 1, +}; +const status = { + revision: 4, + metrics: { requests: 200, http1_requests: 120, http2_requests: 60, http3_requests: 20, + compression_gzip: 10, compression_br: 20, compression_zstd: 5, compression_deflate: 8 }, + cache: { hits: 50, misses: 30 }, + transform_metrics: { applied: 10, cache_hits: 5, skipped: 1, errors: 0 }, + web_server_metrics: { demo: { web, php } }, +}; +function advance(delta) { + for (const key of Object.keys(status.metrics)) status.metrics[key] += delta; + for (const key of ['hits', 'misses']) status.cache[key] += delta; + for (const key of Object.keys(status.transform_metrics)) status.transform_metrics[key] += delta; + for (const key of ['requests', 'status_2xx', 'static_requests', 'php_requests', 'response_headers_us', 'response_bytes']) web[key] += delta; + for (const key of ['requests', 'response_headers_us', 'response_bytes']) php.metrics[key] += delta; +} +function render() { window.rustProxyWideOverview.render(status); window.rustProxyWebOverview.render(status); } +const line = id => ids.get(id).getAttribute('d') || ''; +const compression = 'overview-wide-wide_compression_activity-gzip-line'; +const website = 'www_requests-all-line'; +const phpLine = 'www_php_requests-line'; +const starts = path => (path.match(/M/g) || []).length; + +render(); // no first-interval rate: establish baseline only +now = 4000; advance(12); render(); +const beforeWide = line(compression), beforeWeb = line(website); +const beforePhp = ids.get(phpLine).getAttribute('points'); +assert.equal(starts(beforeWide), 1); +assert.equal(starts(beforeWeb), 1); +assert.ok(beforePhp); + +// Background tab -> a new generation, regardless of counter increments. +document.hidden = true; listeners.get('visibilitychange')(); +document.hidden = false; +now = 100000; advance(600); render(); +assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '-'); +assert.equal(ids.get('www_requests-value').textContent, '-'); +assert.equal(line(compression), beforeWide, 'wide rate chart must not receive a null point'); +assert.equal(line(website), beforeWeb, 'WWW rate chart must not receive a null point'); +assert.equal(ids.get(phpLine).getAttribute('points'), beforePhp, 'small PHP chart must preserve valid history'); + +// A fresh interval then connects to previous valid samples without a gap. +now = 103000; advance(9); render(); +assert.equal(starts(line(compression)), 1, 'wide chart must have one connected path'); +assert.equal(starts(line(website)), 1, 'WWW chart must have one connected path'); +assert.ok(line(compression).includes('L')); +assert.ok(line(website).includes('L')); + +// Real measured zeros must continue to be plotted, not treated as pauses. +now = 106000; render(); +assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '0.0/s'); +assert.equal(ids.get('www_requests-value').textContent, '0.0/s'); +assert.equal(starts(line(compression)), 1); +assert.equal(starts(line(website)), 1); + +assert.match(code('web-server.js'), /if \(record && rate !== null\) \{ previous\.push\(rate\)/, + 'Web Server chart must also skip unavailable rates instead of plotting a zero dip'); +console.log('PASS: resumed wide/WWW/PHP charts stay connected; first invalid rate is unavailable; measured zero persists'); diff --git a/scripts/tests/web_server_0330_ui_test.py b/scripts/tests/web_server_0330_ui_test.py index cd61ef1..de8add9 100755 --- a/scripts/tests/web_server_0330_ui_test.py +++ b/scripts/tests/web_server_0330_ui_test.py @@ -116,6 +116,24 @@ with sync_playwright() as p: }""", second) assert not details.get('missing') and details['rows'] == 7, (width, details) assert '18 / 32' in details['text'], (width, details) + # The first reading after an overdue browser interval must establish a + # fresh baseline without adding a fake 0/s dip to the service chart. + continuity = page.evaluate("""([first, second, paused, resumed]) => { + const api = window.rustProxyWebServer; + const chart = document.querySelector('#web-service-list [data-web-live-id="site-A"] polyline'); + const rate = document.querySelector('#web-service-list [data-web-live-field="rate"]'); + window.__clock = 5000; api.renderLive(first); + window.__clock = 8000; api.renderLive(second); + const before = chart.getAttribute('points'); + window.__clock = 100000; api.renderLive(paused); + const during = chart.getAttribute('points'), unavailable = rate.textContent; + window.__clock = 103000; api.renderLive(resumed); + return { before, during, unavailable, after: chart.getAttribute('points'), resumedRate: rate.textContent }; + }""", [observation(2), observation(3), observation(10), observation(11)]) + assert continuity['before'] == continuity['during'], (width, continuity) + assert continuity['unavailable'] == '-', (width, continuity) + assert continuity['resumedRate'] != '-', (width, continuity) + assert continuity['after'] != continuity['before'], (width, continuity) assert not errors, (width, errors) old = page.evaluate("""() => { currentPanel = 'overview'; diff --git a/src/admin/web/app/overview-wide-metrics.js b/src/admin/web/app/overview-wide-metrics.js index f771678..2ed5917 100644 --- a/src/admin/web/app/overview-wide-metrics.js +++ b/src/admin/web/app/overview-wide-metrics.js @@ -90,11 +90,16 @@ window.rustProxyWideOverview = (() => { : config.values(status).map(finite); const series = history.get(id) || config.names.map(() => []); if (record) { - values.forEach((value, i) => { - series[i].push(value); - if (series[i].length > limit()) series[i].shift(); - }); - history.set(id, series); + // Counter rates have no meaningful value on the first sample after a + // paused tab. Do not add null to the path: it would split a live line. + // Still show "-" above the chart until a fresh interval is sampled. + if (!config.counters || values.every(value => value !== null)) { + values.forEach((value, i) => { + series[i].push(value); + if (series[i].length > limit()) series[i].shift(); + }); + history.set(id, series); + } last.set(id, values); } const peak = Math.max(config.unit === "percent" ? 100 : 1, diff --git a/src/admin/web/app/web-overview.js b/src/admin/web/app/web-overview.js index 9e6b61c..d7f8e99 100644 --- a/src/admin/web/app/web-overview.js +++ b/src/admin/web/app/web-overview.js @@ -154,9 +154,9 @@ window.rustProxyWebOverview = (() => { meter("www_error_rate", r?.errorRate); show("www_error_rate-detail", t("webMetricErrorRateHelp")); show("www_php_requests-value", fmtRate(r?.phpRps)); - if (record) { + if (record && r) { const values = histories.get("www_php_requests") || []; - values.push(r?.phpRps ?? null); window.rustProxyOverviewSamples.trim(values); + values.push(r.phpRps); window.rustProxyOverviewSamples.trim(values); histories.set("www_php_requests", values); } $(`www_php_requests-line`)?.setAttribute("points", smallPath(histories.get("www_php_requests") || [])); @@ -166,7 +166,10 @@ window.rustProxyWebOverview = (() => { }; for (const [id, names] of Object.entries(seriesNames)) { const series = histories.get(id) || names.map(() => []); - if (record) { + // A resumed/overdue interval has no rate; skipping that entire + // snapshot keeps existing paths connected without inventing 0/s. + // A genuinely unavailable series inside a valid interval stays null. + if (record && r) { names.forEach((_, i) => { series[i].push(set[id]?.[i] ?? null); window.rustProxyOverviewSamples.trim(series[i]); }); histories.set(id, series); } diff --git a/src/admin/web/web-server.js b/src/admin/web/web-server.js index 12bedbf..b0b2740 100644 --- a/src/admin/web/web-server.js +++ b/src/admin/web/web-server.js @@ -439,7 +439,8 @@ const deltas = record ? sample.deltas : null; const rate = deltas ? deltas[0] / sample.seconds : null; const previous = serviceHistory.get(id) || []; - if (record) { previous.push(rate); window.rustProxyOverviewSamples.trim(previous); serviceHistory.set(id, previous); } + // An unavailable counter delta is not a 0/s measurement; do not plot it. + if (record && rate !== null) { previous.push(rate); window.rustProxyOverviewSamples.trim(previous); serviceHistory.set(id, previous); } const peak = Math.max(1, ...previous.filter(x => x != null)); const points = previous.map((value, i) => `${300 * i / Math.max(1, previous.length - 1)},${55 - (value || 0) / peak * 50}`).join(" "); panel.querySelector("polyline")?.setAttribute("points", points || "0,55 300,55");