This commit is contained in:
Mateusz Gruszczyński committed 2026-10-15 09:37:47 +02:00
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
View File
@@ -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
View File
@@ -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)')
+134
View File
@@ -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)')