v0.33.0
This commit is contained in:
1 parent
bbfa8cbc3f
commit
2ccc598b12
49 files changed
+4771
-639
No files matched your search
@@ -0,0 +1,133 @@
|
||||
// Deterministic Overview widget regression; simulated DOM/status, no running API.
|
||||
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 html = fs.readFileSync(path.join(root, 'src/admin/web/index.html'), 'utf8');
|
||||
const registry = JSON.parse(fs.readFileSync(path.join(root, 'src/admin/web/dashboard-widgets.json'), 'utf8'));
|
||||
const lang = JSON.parse(fs.readFileSync(path.join(root, 'langs/en_US.json'), 'utf8')).translations;
|
||||
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
|
||||
const widgets = new Map(), ids = new Map();
|
||||
class Element {
|
||||
constructor() { this.hidden = false; this.attributes = new Map(); this.style = {}; this.textContent = '-'; }
|
||||
setAttribute(key, value) { this.attributes.set(key, String(value)); }
|
||||
removeAttribute(key) { this.attributes.delete(key); }
|
||||
getAttribute(key) { return this.attributes.get(key) ?? null; }
|
||||
}
|
||||
for (const [, id] of html.matchAll(/\bid="([^"]+)"/g)) {
|
||||
assert.ok(!ids.has(id), `duplicate DOM id: ${id}`);
|
||||
ids.set(id, new Element());
|
||||
}
|
||||
for (const [, id] of html.matchAll(/data-dashboard-widget="([^"]+)"/g)) widgets.set(id, new Element());
|
||||
assert.equal(registry.length, widgets.size, 'registry and DOM widget count must match');
|
||||
for (const entry of registry) assert.ok(widgets.has(entry.id), `registry references missing DOM card ${entry.id}`);
|
||||
let now = 1000;
|
||||
const document = {
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
querySelector(query) { return widgets.get(query.match(/^\[data-dashboard-widget="([^"]+)"\]$/)?.[1]) ?? null; },
|
||||
};
|
||||
const window = { addEventListener() {}, rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) } };
|
||||
const context = vm.createContext({
|
||||
window, document, performance: { now: () => now },
|
||||
currentPanel: 'overview', $: id => ids.get(id) ?? null,
|
||||
fmt: (v, digits = 0) => Number(v).toFixed(digits),
|
||||
fmtBytes: v => `${Number(v)} B`,
|
||||
t: key => lang[key] || key,
|
||||
console,
|
||||
});
|
||||
for (const script of ['app/rate-sampling.js', 'app/overview-extra-metrics.js', 'app/overview-wide-metrics.js']) {
|
||||
vm.runInContext(code(script), context, { filename: script });
|
||||
}
|
||||
const extra = window.rustProxyExtraOverview, wide = window.rustProxyWideOverview;
|
||||
const status = {
|
||||
revision: 9,
|
||||
system: { open_fds: 60, max_fds: 300 },
|
||||
cache: { hits: 100, misses: 50, resident_bytes: 150, hot_limit_bytes: 300,
|
||||
cold_queue_bytes: 25, cold_queue_limit_bytes: 100, cold_bytes: 400, cold_limit_bytes: 1000 },
|
||||
metrics: { requests: 300, http1_requests: 200, http2_requests: 80, http3_requests: 20,
|
||||
compression_gzip: 10, compression_br: 10, compression_zstd: 10, compression_deflate: 1 },
|
||||
transform_metrics: { applied: 10, cache_hits: 10, skipped: 10, errors: 1 },
|
||||
};
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_memory-meter-fill').style.width, '50%');
|
||||
assert.equal(ids.get('overview-extra-extra_cold_queue_memory-meter-fill').style.width, '25%');
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage-meter-fill').style.width, '40%');
|
||||
widgets.get('extra_cache_memory').hidden = false;
|
||||
widgets.get('extra_cache_hit_rate').hidden = false;
|
||||
widgets.get('extra_h2_share').hidden = false;
|
||||
widgets.get('extra_h1_share').hidden = false;
|
||||
widgets.get('extra_h3_share').hidden = false;
|
||||
widgets.get('extra_process_fds').hidden = false;
|
||||
extra.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_process_fds-meter-fill').style.width, '20%');
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '-'); // first counter sample has no rate
|
||||
assert.equal(ids.get('overview-wide-wide_transform_activity-summary').textContent, '-');
|
||||
|
||||
now = 4000;
|
||||
status.cache.hits += 30;
|
||||
status.cache.misses += 10;
|
||||
status.metrics.requests += 40;
|
||||
status.metrics.http1_requests += 10;
|
||||
status.metrics.http2_requests += 20;
|
||||
status.metrics.http3_requests += 10;
|
||||
status.metrics.compression_gzip += 3;
|
||||
status.metrics.compression_br += 6;
|
||||
status.metrics.compression_zstd += 9;
|
||||
status.metrics.compression_deflate += 12;
|
||||
status.transform_metrics.applied += 15;
|
||||
status.transform_metrics.cache_hits += 3;
|
||||
status.transform_metrics.skipped += 6;
|
||||
status.transform_metrics.errors += 1;
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '75.0%');
|
||||
assert.equal(ids.get('extra-cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '75 25');
|
||||
assert.equal(ids.get('extra-cache-ratio-miss-arc').getAttribute('stroke-dashoffset'), '-75');
|
||||
assert.equal(ids.get('extra-cache-ratio-hit-count').textContent, '30');
|
||||
assert.equal(ids.get('extra-cache-ratio-miss-count').textContent, '10');
|
||||
assert.equal(ids.get('overview-extra-extra_h2_share-meter-fill').style.width, '50%');
|
||||
assert.equal(ids.get('overview-wide-wide_transform_activity-summary').textContent, '8.3/s');
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '10.0/s');
|
||||
for (const name of ['applied', 'cache_hits', 'skipped', 'errors'])
|
||||
assert.ok(ids.get(`overview-wide-wide_transform_activity-${name}-line`).getAttribute('d')?.length);
|
||||
for (const name of ['gzip', 'br', 'zstd', 'deflate'])
|
||||
assert.ok(ids.get(`overview-wide-wide_compression_activity-${name}-line`).getAttribute('d')?.length);
|
||||
|
||||
// No hidden-tab spike or fake zero when a long interval is skipped.
|
||||
now = 100000;
|
||||
status.cache.hits += 300;
|
||||
status.cache.misses += 200;
|
||||
status.metrics.compression_gzip += 600;
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '-');
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '-');
|
||||
now = 103000;
|
||||
status.cache.hits += 5;
|
||||
status.cache.misses += 5;
|
||||
status.metrics.compression_gzip += 3;
|
||||
extra.render(status);
|
||||
wide.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '50.0%');
|
||||
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '1.0/s');
|
||||
|
||||
// Bounded metric with no configured budget remains unknown, not a made-up percentage.
|
||||
status.cache.cold_limit_bytes = 0;
|
||||
extra.render(status);
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage').textContent, '400 B');
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage-meter').getAttribute('aria-valuenow'), null);
|
||||
assert.equal(ids.get('overview-extra-extra_cold_storage-meter-fill').style.width, '0%');
|
||||
|
||||
extra.donut('cache-ratio', 75, 25);
|
||||
assert.equal(ids.get('cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '75 25');
|
||||
assert.equal(ids.get('cache-ratio-chart').getAttribute('aria-label'), 'HIT 75, MISS 25');
|
||||
extra.donut('cache-ratio', 0, 0);
|
||||
assert.equal(ids.get('cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '0 100');
|
||||
assert.ok(code('app/runtime.js').includes('rustProxyExtraOverview?.donut?.("cache-ratio"'),
|
||||
'main HIT/MISS tile must share tested donut rendering');
|
||||
console.log('PASS: bounded meters, COLD tiers, HIT/MISS rings, transform and compression rates, pause/no-limit');
|
||||
@@ -0,0 +1,114 @@
|
||||
// Simulated browser/status contract: this does not exercise the Rust server.
|
||||
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 read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const html = read('src/admin/web/index.html');
|
||||
const controllerSource = read('src/admin/web/app/overview-samples.js');
|
||||
const store = new Map();
|
||||
|
||||
assert.match(html, /id="overview-layout-unlock"/);
|
||||
// History controls share the connected-core eyebrow row (not a separate
|
||||
// dashboard row), and navigation shows them only on Overview.
|
||||
const metaStart = html.indexOf('class="page-intro-meta-row"');
|
||||
const metaEnd = html.indexOf('class="page-intro-title-row"');
|
||||
const statusPos = html.indexOf('id="connection-label"');
|
||||
const selectPos = html.indexOf('id="overview-live-samples"');
|
||||
assert.ok(metaStart < statusPos && statusPos < selectPos && selectPos < metaEnd);
|
||||
assert.ok(selectPos < html.indexOf('id="panel-overview"'));
|
||||
assert.match(html, /id="overview-samples-toolbar" hidden role="group"/);
|
||||
assert.match(read('src/admin/web/app/core.js'), /\$\("overview-samples-toolbar"\)\.hidden = name !== "overview"/);
|
||||
assert.ok(html.indexOf('id="overview-live-samples"') < html.indexOf('id="overview-layout-unlock"'));
|
||||
assert.ok(html.indexOf('id="overview-live-samples"') < html.indexOf('id="dashboard-overview-grid"'));
|
||||
for (const sample of [60, 120, 300, 600]) assert.match(html, new RegExp(`<option value="${sample}">`));
|
||||
|
||||
class Element {
|
||||
constructor() { this.listeners = {}; this.value = ''; this.dataset = {}; this.disabled = false; this.attributes = new Map(); this.textContent = ''; this.hidden = false; }
|
||||
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||
fire(name) { this.listeners[name](); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name); }
|
||||
}
|
||||
const nodes = new Map();
|
||||
for (const id of ['overview-live-samples', 'overview-samples-save', 'overview-samples-state']) nodes.set(id, new Element());
|
||||
const widgets = new Map([['wide_compression_activity', new Element()]]);
|
||||
const document = {
|
||||
readyState: 'complete', getElementById: id => nodes.get(id) || null,
|
||||
querySelector: query => widgets.get(query.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null,
|
||||
};
|
||||
const localStorage = {
|
||||
getItem: key => store.get(key) ?? null,
|
||||
setItem: (key, value) => { store.set(key, value); },
|
||||
};
|
||||
let now = 1000;
|
||||
const window = { rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) } };
|
||||
const context = vm.createContext({ document, localStorage, window, queueMicrotask: callback => callback(),
|
||||
t: key => key, performance: { now: () => now },
|
||||
currentPanel: 'overview', latestStatus: null, $: id => { if (!nodes.has(id)) nodes.set(id, new Element()); return nodes.get(id); },
|
||||
fmt: n => String(n), fmtBytes: n => `${n} B`,
|
||||
});
|
||||
vm.runInContext(controllerSource, context);
|
||||
const samples = window.rustProxyOverviewSamples;
|
||||
const select = document.getElementById('overview-live-samples');
|
||||
const save = document.getElementById('overview-samples-save');
|
||||
const state = document.getElementById('overview-samples-state');
|
||||
assert.equal(samples.getLimit(), 60);
|
||||
assert.equal(save.disabled, true);
|
||||
select.value = '600'; select.fire('change');
|
||||
assert.equal(samples.getLimit(), 600);
|
||||
assert.equal(state.dataset.i18n, 'overviewSamplesTemporary');
|
||||
assert.equal(store.size, 0, 'temporary samples must not touch localStorage');
|
||||
assert.equal(save.disabled, false);
|
||||
const range = Array.from({ length: 700 }, (_, i) => i);
|
||||
let calls = 0;
|
||||
samples.onChange(() => { samples.trim(range); calls++; });
|
||||
select.value = '120'; select.fire('change');
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(range.length, 120);
|
||||
assert.equal(range[0], 580);
|
||||
assert.equal(samples.setLimit(999), false);
|
||||
assert.equal(samples.getLimit(), 120);
|
||||
save.fire('click');
|
||||
assert.equal(store.get('rustproxy.overview.liveChartSamples'), '120');
|
||||
assert.equal(state.dataset.i18n, 'overviewSamplesSaved');
|
||||
assert.equal(save.disabled, true);
|
||||
|
||||
// Fresh load reads only the saved choice, not a temporary override.
|
||||
select.value = '300'; select.fire('change');
|
||||
assert.equal(samples.getLimit(), 300);
|
||||
const freshDocument = { readyState: 'loading', addEventListener() {} };
|
||||
const fresh = vm.createContext({ document: freshDocument, localStorage, window: {}, t: key => key });
|
||||
vm.runInContext(controllerSource, fresh);
|
||||
assert.equal(fresh.window.rustProxyOverviewSamples.getLimit(), 120);
|
||||
// Permanently saved choice survives a fresh load.
|
||||
save.fire('click');
|
||||
const again = vm.createContext({ document: freshDocument, localStorage, window: {}, t: key => key });
|
||||
vm.runInContext(controllerSource, again);
|
||||
assert.equal(again.window.rustProxyOverviewSamples.getLimit(), 300);
|
||||
|
||||
// Wide Overview charts must respect the same limit and redraw immediately when
|
||||
// the limit shrinks, without recording an extra point or hitting the network.
|
||||
vm.runInContext(read('src/admin/web/app/rate-sampling.js'), context);
|
||||
vm.runInContext(read('src/admin/web/app/overview-wide-metrics.js'), context);
|
||||
select.value = '600'; select.fire('change');
|
||||
const status = {
|
||||
revision: 1, metrics: { compression_gzip: 0, compression_br: 0, compression_zstd: 0, compression_deflate: 0 },
|
||||
};
|
||||
context.latestStatus = status;
|
||||
for (let i = 0; i <= 650; i++) {
|
||||
now += 3000;
|
||||
for (const key of ['compression_gzip', 'compression_br', 'compression_zstd', 'compression_deflate']) status.metrics[key] += i % 3 + 1;
|
||||
window.rustProxyWideOverview.render(status);
|
||||
}
|
||||
const line = () => nodes.get('overview-wide-wide_compression_activity-gzip-line').getAttribute('d');
|
||||
assert.equal((line().match(/ L/g) || []).length, 599, 'maximum 600 samples must be retained');
|
||||
select.value = '120'; select.fire('change');
|
||||
assert.equal((line().match(/ L/g) || []).length, 119, 'changing selection must trim and redraw without a new sample');
|
||||
assert.equal(store.get('rustproxy.overview.liveChartSamples'), '300', 'temporary choice must preserve the saved limit');
|
||||
assert.ok(!read('src/admin/web/app/overview-samples.js').includes('fetch('));
|
||||
assert.ok(!read('src/admin/web/app/overview-samples.js').includes('setInterval('));
|
||||
console.log('PASS: Overview samples 60/120/300/600, temporary vs persistent, immediate trim/redraw, no extra requests');
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
from pathlib import Path
|
||||
import re, json
|
||||
from playwright.sync_api import sync_playwright
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
router=(root/'src/admin/router.rs').read_text()
|
||||
|
||||
def concatenate(section):
|
||||
pattern=r'fn '+section+r'\([^)]*\)[^{]*\{'
|
||||
m=re.search(pattern,router)
|
||||
assert m, section
|
||||
text=router[m.end():]
|
||||
# The array contents are bounded by ].join("\\n") before the next function.
|
||||
text=text[:re.search(r'\]\s*\.join\("\\n"\)', text).start()]
|
||||
result=[]
|
||||
for line in text.splitlines():
|
||||
line=line.strip()
|
||||
if 'include_str!("' in line:
|
||||
path=re.search(r'include_str!\("([^"]+)"\)',line).group(1)
|
||||
result.append((root/'src/admin'/path).read_text())
|
||||
elif line=='admin_vhosts_javascript(),':
|
||||
result.append('(() => {\n'+''.join((root/'src/admin/web/vhosts'/f'{part}.js').read_text()+'\n' for part in ['core','protocol','health','rates','editor','render'])+'})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
result.append(json.loads(line[:-1]))
|
||||
return '\n'.join(result)
|
||||
bundle=concatenate('admin_app_javascript')
|
||||
assert 'window.rustProxyOverviewSamples =' in bundle
|
||||
assert bundle.index('window.rustProxyOverviewSamples =') < bundle.index('window.rustProxyOverviewSamples.onChange(')
|
||||
css=(root/'src/admin/web/style.css').read_text()
|
||||
html=(root/'src/admin/web/index.html').read_text()
|
||||
with sync_playwright() as p:
|
||||
browser=p.chromium.launch(headless=True, executable_path='/usr/bin/chromium',args=['--no-sandbox','--allow-file-access-from-files'])
|
||||
for width in [320,375,800,1440]:
|
||||
page=browser.new_page(viewport={'width':width,'height':900})
|
||||
errors=[]
|
||||
page.on('pageerror',lambda e: errors.append(str(e)))
|
||||
# Execute in a fully populated DOM without disallowed filesystem/network navigation.
|
||||
page.set_content(html.replace('/assets/app___RUSTPROXY_ASSET_ID__.js','').replace('/assets/style___RUSTPROXY_ASSET_ID__.css',''))
|
||||
page.add_style_tag(content=css)
|
||||
page.add_script_tag(content=bundle)
|
||||
page.wait_for_timeout(550)
|
||||
state=page.evaluate('''() => ({ controller:typeof window.rustProxyOverviewSamples,
|
||||
handle:typeof window.rustProxyOverviewSamples?.onChange,
|
||||
web:typeof window.rustProxyWebOverview, services:typeof window.rustProxyWebServer,
|
||||
registry: document.querySelectorAll('[data-dashboard-widget^="www_"]').length,
|
||||
htmlWidth:document.documentElement.scrollWidth,windowWidth:window.innerWidth })''')
|
||||
print(width,state,'errors',errors)
|
||||
assert state['controller']=='object' and state['handle']=='function' and state['web']=='object'
|
||||
assert state['registry']==12
|
||||
assert not [e for e in errors if 'onChange' in e or 'ReferenceError' in e or 'SyntaxError' in e or 'TypeError' in e],errors
|
||||
assert state['htmlWidth'] <= state['windowWidth'] + 2
|
||||
# The selector belongs to the same header row as the core connection;
|
||||
# hiding it on other panels must not leave an empty dashboard row.
|
||||
layout = page.evaluate('''() => {
|
||||
document.getElementById('workspace').hidden = false;
|
||||
setPanel('overview', false);
|
||||
const status = document.getElementById('connection-label');
|
||||
const sample = document.getElementById('overview-samples-toolbar');
|
||||
const row = document.querySelector('.page-intro-meta-row');
|
||||
const rect = node => { const r = node.getBoundingClientRect();
|
||||
return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right}; };
|
||||
const overview = { status:rect(status),sample:rect(sample),row:rect(row),
|
||||
visible: !sample.hidden,inside: row.contains(sample) && row.contains(status),
|
||||
overflow: document.documentElement.scrollWidth > innerWidth + 2 };
|
||||
setPanel('web-server', false);
|
||||
const notOverview = sample.hidden && !status.hidden;
|
||||
setPanel('overview', false);
|
||||
return {overview,notOverview,visibleAgain:!sample.hidden};
|
||||
}''')
|
||||
assert layout['overview']['inside'] and layout['overview']['visible']
|
||||
assert layout['notOverview'] and layout['visibleAgain']
|
||||
assert not layout['overview']['overflow'], (width, layout)
|
||||
if width >= 800:
|
||||
assert abs(layout['overview']['sample']['y'] - layout['overview']['status']['y']) <= 20, (width, layout)
|
||||
assert layout['overview']['sample']['right'] <= layout['overview']['row']['right'] + 2, (width, layout)
|
||||
# A genuinely sampled WWW metrics payload (not a screenshot-only fixture).
|
||||
report = page.evaluate('''() => {
|
||||
Object.defineProperty(window.performance, "now", {configurable:true, value:()=>window.__clock});
|
||||
window.__clock = 1000;
|
||||
currentPanel = "overview";
|
||||
document.getElementById('workspace').hidden = false;
|
||||
document.getElementById('login').hidden = true;
|
||||
document.getElementById('panel-overview').hidden = false;
|
||||
for (const n of document.querySelectorAll('[data-dashboard-widget^="www_"]')) n.hidden = false;
|
||||
function snap(k) {
|
||||
return {revision:7, web_server_metrics:{
|
||||
"public-site": {web: {requests:100+k*30, status_2xx:86+k*24, status_3xx:3+k,
|
||||
status_4xx:8+k*2, status_5xx:3+k*3, response_headers_us:4000000+k*1300000,
|
||||
response_bytes:100000+k*20000, static_requests:70+k*20, php_requests:25+k*7,
|
||||
other_requests:5+k*3, methods:[90+k*25,8+k*4,0,0,2+k],
|
||||
selected_status:[80+k*18,2,2,1,5+k,0,0,0], active_headers:0},
|
||||
php: {metrics:{requests:25+k*7, response_headers_us:2000000+k*700000,
|
||||
response_bytes:30000+k*5000,failures:[1+k,1+k,1+k,0,0,0,0,0,0]},
|
||||
capacity_limit:32, capacity_used:8, capacity_available:24}}}
|
||||
};
|
||||
}
|
||||
const before = snap(0), after = snap(1);
|
||||
// Saved custom layout from before 0.33.0 must not enable new cards.
|
||||
const savedLayout = applyDashboardLayout(['requests', 'errors'],
|
||||
normalizeHiddenDashboardWidgets([], ['requests', 'errors']));
|
||||
const newCardsHidden = [...document.querySelectorAll('[data-dashboard-widget^="www_"]')]
|
||||
.every(card=>card.hidden);
|
||||
for (const n of document.querySelectorAll('[data-dashboard-widget^="www_"]')) n.hidden = false;
|
||||
window.rustProxyWebOverview.render(before, true);
|
||||
window.__clock = 4000;
|
||||
window.rustProxyWebOverview.render(after, true);
|
||||
let ids = ['www_requests-all-line', 'www_requests-php-line',
|
||||
'www_latency-web-line', 'www_traffic-php-line', 'www_status_trend-5xx-line'];
|
||||
const linePaths = ids.map(id=>document.getElementById(id)?.getAttribute('d'));
|
||||
const statusText = document.getElementById('www_status-value').textContent;
|
||||
const capacityText = document.getElementById('www_capacity-value').textContent;
|
||||
const rateText = document.getElementById('www_php_requests-value').textContent;
|
||||
const bars = document.querySelectorAll('#www_top_services-bars .www-top-row').length;
|
||||
window.rustProxyOverviewSamples.setLimit(120);
|
||||
const historyLimit = window.rustProxyOverviewSamples.getLimit();
|
||||
currentPanel = "web-server";
|
||||
envelope = {config:{web_services:[{id:'public-site', name:'Public site',document_root:'/srv/www/public'}],vhosts:[]}};
|
||||
window.rustProxyWebServer.render();
|
||||
const details = document.querySelector('.www-service-panel');
|
||||
details.open = true;
|
||||
window.__clock = 7000;
|
||||
window.rustProxyWebServer.renderLive(before, true);
|
||||
window.__clock = 10000;
|
||||
window.rustProxyWebServer.renderLive(after, true);
|
||||
const value = details.querySelector('[data-web-live-field="requests"]')?.textContent;
|
||||
const phpCapacity = details.querySelector('[data-web-live-field="php"]')?.textContent;
|
||||
return {linePaths,statusText,capacityText,rateText,bars,historyLimit,value,phpCapacity,newCardsHidden,
|
||||
width:document.documentElement.scrollWidth};
|
||||
}''')
|
||||
print('sampled WWW',width,report)
|
||||
assert all(report['linePaths']), report
|
||||
assert report['statusText'] != '-' and report['capacityText'] == '8 / 32', report
|
||||
assert report['rateText'] not in ('-', '0.0/s'), report
|
||||
assert report['bars'] == 1 and report['historyLimit'] == 120 and report['newCardsHidden'], report
|
||||
assert report['value'] and report['phpCapacity'] == '8 / 32', report
|
||||
assert report['width'] <= width + 2, report
|
||||
assert not [e for e in errors if any(x in e for x in ('ReferenceError', 'TypeError', 'SyntaxError'))], errors
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS: actual composed admin bundle starts without a JS TypeError')
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static contracts for 0.33.0 WWW observability (not Rust compilation)."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
read = lambda file: (ROOT / file).read_text()
|
||||
manifest = tomllib.loads(read('Cargo.toml'))
|
||||
lock = tomllib.loads(read('Cargo.lock'))
|
||||
version = manifest['package']['version']
|
||||
assert version == '0.33.0'
|
||||
assert [p['version'] for p in lock['package'] if p['name'] == 'rust-proxy'] == [version]
|
||||
|
||||
registry = json.loads(read('src/admin/web/dashboard-widgets.json'))
|
||||
widgets = [w for w in registry if w['id'].startswith('www_')]
|
||||
assert len(widgets) == 12
|
||||
assert all(w['default_hidden'] is True for w in widgets)
|
||||
assert len({w['id'] for w in registry}) == len(registry)
|
||||
html = read('src/admin/web/index.html')
|
||||
english = json.loads(read('langs/en_US.json'))['translations']
|
||||
polish = json.loads(read('langs/pl_pl.json'))['translations']
|
||||
for widget in widgets:
|
||||
assert f'data-dashboard-widget="{widget["id"]}" hidden' in html, widget['id']
|
||||
assert widget['label_key'] in english, widget['label_key']
|
||||
assert widget['label_key'] in polish, widget['label_key']
|
||||
if widget['description_key']:
|
||||
assert widget['description_key'] in english
|
||||
assert widget['description_key'] in polish
|
||||
|
||||
# Safely assemble the admin app before core.js: the earlier release crashed on
|
||||
# registering onChange callbacks before rustProxyOverviewSamples was defined.
|
||||
router = read('src/admin/router.rs')
|
||||
assert router.index('include_str!("web/app/overview-samples.js")') < router.index('include_str!("web/app/core.js")')
|
||||
assert '"web/app/web-overview.js"' in router
|
||||
assert '"web_server_metrics"' in read('src/admin/status_live.rs')
|
||||
assert 'web_server_metrics_payload(&snapshot)' in read('src/admin/status.rs')
|
||||
# record_web is only consumed by web_server::serve; expose no wider than the
|
||||
# ResponseKind enum argument. This is a static visibility contract, not rustc.
|
||||
metrics = read('src/web_server/metrics.rs')
|
||||
assert 'pub(super) enum ResponseKind' in metrics
|
||||
assert 'pub(super) fn record_web(' in metrics
|
||||
assert 'pub(crate) fn record_web(' not in metrics
|
||||
assert 'service.web_metrics.record_web(' in read('src/web_server/serve.rs')
|
||||
|
||||
families = read('src/prometheus/web_php.rs')
|
||||
required = {
|
||||
'rust_proxy_web_server_requests_total', 'rust_proxy_web_server_responses_total',
|
||||
'rust_proxy_web_server_request_kind_total', 'rust_proxy_web_server_methods_total',
|
||||
'rust_proxy_web_server_status_code_total', 'rust_proxy_web_server_response_header_duration_microseconds_total',
|
||||
'rust_proxy_web_server_response_body_bytes_total', 'rust_proxy_web_server_requests_active',
|
||||
'rust_proxy_web_server_compression_total', 'rust_proxy_php_requests_total',
|
||||
'rust_proxy_php_responses_total', 'rust_proxy_php_response_header_duration_microseconds_total',
|
||||
'rust_proxy_php_response_body_bytes_total', 'rust_proxy_php_concurrency_available',
|
||||
'rust_proxy_php_concurrency_limit', 'rust_proxy_php_concurrency_in_use', 'rust_proxy_php_failures_total'
|
||||
}
|
||||
assert all(f'"{name}"' in families for name in required), required
|
||||
assert 'PHP_FAILURE_NAMES' in families
|
||||
assert re.search(r'\["GET", "POST", "PUT", "DELETE", "other"\]', families)
|
||||
assert 'let wl = labels(&[("web_service", service.id.as_str())]);' in families
|
||||
|
||||
j = json.loads(read('grafana/rustproxy-overview.json'))
|
||||
assert j['title'].startswith('RustProxy 0.33.0')
|
||||
ids = [p['id'] for p in j['panels']]
|
||||
assert len(ids) == len(set(ids))
|
||||
assert any(v.get('name') == 'web_service' and v.get('multi') and v.get('includeAll') for v in j['templating']['list'])
|
||||
for title in ('Web Server 0.33.0', 'PHP-FPM / FastCGI', 'Per Web Service', 'WWW / PHP correlation'):
|
||||
assert any(p['title'] == title and p['type'] == 'row' for p in j['panels'])
|
||||
extra = [p for p in j['panels'] if p['id'] >= 89]
|
||||
for i, p in enumerate(extra):
|
||||
a = p['gridPos']
|
||||
assert 0 <= a['x'] and a['w'] > 0 and a['x'] + a['w'] <= 24
|
||||
for q in extra[i + 1:]:
|
||||
b = q['gridPos']
|
||||
overlap = a['x'] < b['x'] + b['w'] and b['x'] < a['x'] + a['w'] and a['y'] < b['y'] + b['h'] and b['y'] < a['y'] + a['h']
|
||||
assert not overlap, (p['id'], q['id'])
|
||||
|
||||
production = [p for p in (ROOT / 'src').rglob('*.rs') if p.name != 'tests.rs' and 'tests' not in p.parts]
|
||||
large = [str(p.relative_to(ROOT)) for p in production if len(p.read_text().splitlines()) > 500]
|
||||
assert not large, large
|
||||
print('PASS RustProxy 0.33.0 source version, opt-in widgets, localizations, status API,')
|
||||
print(' exporter families, Grafana geometry/filtering and Rust module size (STATIC ONLY)')
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simulated Web Server telemetry against the production admin JS/CSS bundle.
|
||||
|
||||
Browser-only test: does not assert live RustProxy, PHP-FPM, or network behavior.
|
||||
Run: python3 scripts/tests/web_server_0330_ui_test.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
router = (ROOT / 'src/admin/router.rs').read_text()
|
||||
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', router)
|
||||
assert match, 'admin_app_javascript definition missing'
|
||||
portion = router[match.end():]
|
||||
portion = portion[:re.search(r'\]\s*\.join\("\\n"\)', portion).start()]
|
||||
fragments = []
|
||||
for line in portion.splitlines():
|
||||
line = line.strip()
|
||||
if 'include_str!("' in line:
|
||||
path = re.search(r'include_str!\("([^"]+)"\)', line).group(1)
|
||||
fragments.append((ROOT / 'src/admin' / path).read_text())
|
||||
elif line == 'admin_vhosts_javascript(),':
|
||||
fragments.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / f'{part}.js').read_text() for part in ['core', 'protocol', 'health', 'rates', 'editor', 'render']) + '\n})();')
|
||||
elif line.startswith('"') and line.endswith('",'):
|
||||
fragments.append(json.loads(line[:-1]))
|
||||
BUNDLE = '\n'.join(fragments)
|
||||
assert BUNDLE.index('window.rustProxyOverviewSamples =') < BUNDLE.index('window.rustProxyOverviewSamples.onChange(')
|
||||
|
||||
WEB = {
|
||||
'requests': 100, 'status_2xx': 90, 'status_3xx': 2, 'status_4xx': 5, 'status_5xx': 3,
|
||||
'response_headers_us': 2_500_000, 'response_bytes': 10_000, 'active_headers': 2,
|
||||
'static_requests': 70, 'php_requests': 25, 'other_requests': 5,
|
||||
'methods': [80, 10, 5, 2, 3], 'selected_status': [75, 5, 2, 0, 5, 1, 1, 1],
|
||||
'failures': [0] * 9, 'compression': [5, 3, 2, 1],
|
||||
}
|
||||
PHP = {
|
||||
'requests': 25, 'status_2xx': 22, 'status_3xx': 0, 'status_4xx': 1, 'status_5xx': 2,
|
||||
'response_headers_us': 1_000_000, 'response_bytes': 2_000,
|
||||
'failures': [1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
}
|
||||
|
||||
def observation(factor: int):
|
||||
web = {key: ([factor * v for v in value] if isinstance(value, list) else factor * value)
|
||||
for key, value in WEB.items()}
|
||||
php = {key: ([factor * v for v in value] if isinstance(value, list) else factor * value)
|
||||
for key, value in PHP.items()}
|
||||
web['active_headers'] = 2
|
||||
return {'revision': 10, 'web_server_metrics': {
|
||||
'site-A': {'web': web, 'php': {'metrics': php, 'capacity_limit': 32, 'capacity_used': 18}}
|
||||
}}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
||||
for width in (320, 375, 800, 1440):
|
||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
||||
errors = []
|
||||
page.on('pageerror', lambda err: errors.append(str(err)))
|
||||
page.set_content((ROOT / 'src/admin/web/index.html').read_text())
|
||||
page.add_style_tag(content=(ROOT / 'src/admin/web/style.css').read_text())
|
||||
page.add_script_tag(content=BUNDLE)
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#workspace').hidden = false;
|
||||
document.querySelector('#panel-overview').hidden = false;
|
||||
document.querySelectorAll('[data-dashboard-widget^="www_"]').forEach(n => n.hidden = false);
|
||||
currentPanel = 'overview';
|
||||
window.__clock = 1000;
|
||||
Object.defineProperty(performance, 'now', { configurable: true, value: () => window.__clock });
|
||||
}""")
|
||||
first, second = observation(1), observation(2)
|
||||
state = page.evaluate("""([one, two]) => {
|
||||
const api = window.rustProxyWebOverview;
|
||||
api.render(one);
|
||||
window.__clock = 4000;
|
||||
api.render(two);
|
||||
return {
|
||||
rps: document.getElementById('www_requests-all-value').textContent,
|
||||
php: document.getElementById('www_requests-php-value').textContent,
|
||||
latency: document.getElementById('www_latency-web-value').textContent,
|
||||
capacity: document.getElementById('www_capacity-value').textContent,
|
||||
types: document.getElementById('www_kind-value').textContent,
|
||||
status: document.getElementById('www_status-value').textContent,
|
||||
bytes: document.getElementById('www_traffic-value').textContent,
|
||||
top: document.getElementById('www_top_services-value').textContent,
|
||||
layoutWidth: document.documentElement.scrollWidth,
|
||||
screenWidth: window.innerWidth,
|
||||
children: document.querySelectorAll('[data-dashboard-widget^="www_"]').length,
|
||||
sampleCount: document.getElementById('www_requests-all-line').getAttribute('d'),
|
||||
limit: window.rustProxyOverviewSamples.getLimit(),
|
||||
};
|
||||
}""", [first, second])
|
||||
assert state['children'] == 12, state
|
||||
assert state['rps'] != '-' and state['php'] != '-', state
|
||||
assert 'ms' in state['latency'] and '18' in state['capacity'], state
|
||||
assert state['types'].endswith('%') and state['status'].endswith('%'), state
|
||||
assert '/s' in state['top'], state
|
||||
assert state['layoutWidth'] <= state['screenWidth'] + 2, (width, state)
|
||||
assert not errors, (width, errors)
|
||||
# Exercise the Web Server per-service accordion with the same status,
|
||||
# without any real HTTP/PHP request or extra polling.
|
||||
details = page.evaluate("""(status) => {
|
||||
envelope = { revision: 10, config: {
|
||||
web_services: [{ id: 'site-A', name: 'Example', document_root: '/var/www/site-A',
|
||||
index: ['index.html'], php: { enabled: true } }],
|
||||
vhosts: [], php_fpm_profiles: []
|
||||
}};
|
||||
currentPanel = 'web-server';
|
||||
window.rustProxyWebServer.render();
|
||||
const detail = document.querySelector('#web-service-list .www-service-panel');
|
||||
if (!detail) return { missing: true };
|
||||
detail.open = true;
|
||||
window.rustProxyWebServer.renderLive(status, false);
|
||||
return { text: detail.textContent, rows: detail.querySelectorAll('[data-web-live-field]').length };
|
||||
}""", second)
|
||||
assert not details.get('missing') and details['rows'] == 7, (width, details)
|
||||
assert '18 / 32' in details['text'], (width, details)
|
||||
assert not errors, (width, errors)
|
||||
old = page.evaluate("""() => {
|
||||
currentPanel = 'overview';
|
||||
window.rustProxyWebOverview.render({ revision: 11, web_server_metrics: {} });
|
||||
return {
|
||||
rate: document.getElementById('www_requests-value').textContent,
|
||||
status: document.getElementById('www_status-value').textContent,
|
||||
top: document.getElementById('www_top_services-bars').childElementCount,
|
||||
};
|
||||
}""")
|
||||
assert old == {'rate': '-', 'status': '-', 'top': 0}, (width, old)
|
||||
assert not errors, (width, errors)
|
||||
print(f'PASS simulated Web Server cards and service accordion at {width}px: {state["rps"]}, {state["php"]}, layout {state["layoutWidth"]}px')
|
||||
page.close()
|
||||
browser.close()
|
||||
print('PASS production JS bundle + all twelve visible WWW widgets (simulated data only)')
|
||||
Reference in new issue
Block a user