This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 21:29:57 +02:00
1 parent bb025dcf44
commit b0f211555a
74 files changed
+6089 -556

No files matched your search

@@ -0,0 +1,77 @@
// Offline UI/theme contract: selectors, cached appearance, and CSS readability.
// Does not substitute for a browser session or Rust API integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const base = 'src/admin/web/';
const css = fs.readFileSync(`${base}style.css`, 'utf8');
const html = fs.readFileSync(`${base}index.html`, 'utf8');
const login = fs.readFileSync(`${base}login.js`, 'utf8');
const app = fs.readFileSync(`${base}app/preferences.js`, 'utf8');
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const themes = ['dark', 'ultra_dark', 'light', 'cream'];
const declaration = app.match(/^const ADMIN_THEMES = .*;$/m)?.[0];
const normalize = app.match(/^function normalizeAdminTheme\(value\) \{[^\n]*\}$/m)?.[0];
const apply = app.split('function applyAdminTheme(value, cache = true) {')[1]
?.split('function applyAdminLanguage(')[0];
assert(declaration && normalize && apply);
const stored = new Map();
const select = { value: '' };
const root = { dataset: {} };
const ctx = {
document: { documentElement: root },
$: id => id === 'ui-theme' ? select : null,
localStorage: { setItem: (k, v) => stored.set(k, v), getItem: k => stored.get(k) },
};
vm.createContext(ctx);
vm.runInContext(`${declaration}\n${normalize}\nfunction applyAdminTheme(value, cache = true) {${apply}`, ctx);
for (const theme of themes) {
assert(html.includes(`value="${theme}"`), `option ${theme}`);
assert(login.includes('"ultra_dark", "light", "cream"'), 'cached theme also applies at login');
assert(rust.includes('"dark" | "ultra_dark" | "light" | "cream"'));
assert.equal(vm.runInContext(`normalizeAdminTheme("${theme}")`, ctx), theme);
assert.equal(vm.runInContext(`applyAdminTheme("${theme}")`, ctx), theme);
assert.equal(root.dataset.theme, theme);
assert.equal(select.value, theme);
assert.equal(stored.get('rp-theme'), theme);
}
assert.equal(vm.runInContext('normalizeAdminTheme("unknown")', ctx), 'dark');
vm.runInContext('applyAdminTheme("ultra_dark", false)', ctx);
assert.equal(stored.get('rp-theme'), 'cream', 'preview without caching preserves selected preference');
assert(en.themeUltraDark && en.themeCream && pl.themeUltraDark && pl.themeCream);
assert.equal(schema.components.schemas.AdminPreferences.properties.theme.enum.length, 4);
function block(selector) {
const start = css.indexOf(`${selector} {`);
assert(start >= 0, selector);
return css.slice(start, css.indexOf('\n}', start) + 2);
}
function token(section, key) {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = section.match(new RegExp(`--${escaped}:\\s*(#[0-9a-fA-F]{6});`));
assert(match, `missing ${key}`);
return match[1];
}
function luminance(hex) {
const rgb = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
for (const theme of ['ultra_dark', 'cream']) {
const section = block(`html[data-theme="${theme}"]`);
const bg = token(section, 'bg'), panel = token(section, 'panel'), panel2 = token(section, 'panel2');
const text = token(section, 'text'), muted = token(section, 'muted'), accent = token(section, 'accent');
for (const [name, foreground, surface] of [
['text on background', text, bg], ['text on panel', text, panel],
['muted on card', muted, panel2], ['accent on background', accent, bg],
]) assert(contrast(foreground, surface) >= 4.5, `${theme}: ${name}`);
}
console.log('PASS: four account themes, persisted and cached selection, server API contract, and accessible black/cream palette contrast');
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Optional Chromium checks for Appearance controls and global corner geometry.
Uses shipped CSS and the real Appearance list-rendering function with mocked
preferences/translation state. Not a test of the authenticated Rust service.
"""
from pathlib import Path
import json
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
css = (WEB / 'style.css').read_text()
prefs = (WEB / 'app/preferences.js').read_text()
widgets = json.loads((WEB / 'dashboard-widgets.json').read_text())
translations = json.loads((ROOT / 'langs/en_US.json').read_text())['translations']
functions = prefs[:prefs.index('function browserLanguage()')] + '\n' + prefs[prefs.index('function dashboardWidgetLabel'):prefs.index('function refreshChartStylePalette')]
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="default"><head><style>{css}</style></head><body>'
'<div id="settings-page-appearance" class="settings-page" style="max-width:100%;padding:12px">'
'<div class="appearance-section"><div class="dashboard-widget-list" id="dashboard-widget-list"></div></div>'
'<div style="display:flex;gap:10px;margin-top:12px">'
'<div class="card" id="test-card">Card</div><div class="stat" id="test-stat">Stat</div>'
'<button class="outline" id="test-action">Action</button><input id="test-input" value="value"></div>'
'<a class="brand" href="#"><span class="brand-mark" id="test-logo">r<span>/</span>p</span>RustProxy</a></div>'
'<div class="overview-widget-type" id="editor-type-badge">Chart</div></body></html>')
js = f'''const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({json.dumps(widgets)});
const translations = {json.dumps(translations)};
function t(key) {{ return translations[key] || key; }}
function $(id) {{ return document.getElementById(id); }}
function el(tag, text, klass) {{ const node = document.createElement(tag); if (text != null) node.textContent = text; if (klass) node.className = klass; return node; }}
{functions}
renderDashboardWidgetSettings();'''
with sync_playwright() as playwright:
binary = shutil.which('chromium') or shutil.which('chromium-browser')
browser = playwright.chromium.launch(headless=True, executable_path=binary, args=['--no-sandbox'])
page = browser.new_page(viewport={'width': 1320, 'height': 950})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=js)
assert page.locator('#dashboard-widget-list .dashboard-widget-setting').count() == 62
assert page.locator('#settings-page-appearance .overview-widget-type').count() == 0
assert page.locator('#editor-type-badge').is_visible()
for width in (1320, 790, 450, 345):
page.set_viewport_size({'width': width, 'height': 950})
results = page.locator('.dashboard-widget-setting').evaluate_all('''rows => rows.map(row => {
const label = row.querySelector('.dashboard-widget-toggle').getBoundingClientRect();
const actions = row.querySelector('.dashboard-widget-setting-actions').getBoundingClientRect();
const box = row.getBoundingClientRect();
return { labelRight: label.right, actionsLeft: actions.left, actionsRight: actions.right, right: box.right };
})''')
for i, result in enumerate(results):
assert result['actionsLeft'] >= result['labelRight'] - 1, (width, i, result)
assert result['actionsRight'] <= result['right'] + 1, (width, i, result)
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 2'), width
for theme in ('dark', 'light', 'ultra_dark', 'cream'):
page.evaluate('theme => { document.documentElement.dataset.theme = theme; document.documentElement.dataset.cornerStyle = "default"; }', theme)
stat = page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
card = page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
assert stat == card == '0px', (theme, stat, card)
for style, expected_surface, expected_control in (('square', '0px', '0px'), ('subtle','5px','4px'), ('rounded','12px','8px'),('extra','18px','12px')):
page.evaluate('style => document.documentElement.dataset.cornerStyle = style', style)
assert page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'stat')
assert page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'card')
assert page.locator('#test-action').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'button')
assert page.locator('#test-input').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'input')
page.evaluate('document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.cornerStyle = "default"')
original_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
page.evaluate('document.documentElement.dataset.theme = "ultra_dark"')
black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
assert black_logo == original_logo == 'rgb(246, 155, 116)', (black_logo, original_logo)
out = ROOT.parent / 'appearance_ux_0323_preview.png'
page.screenshot(path=str(out))
browser.close()
print('PASS: 62 badge-free Appearance rows, right-pinned buttons at 4 widths, theme-independent corner geometry, original black-theme logo accent')
@@ -0,0 +1,96 @@
// Regression contract: Appearance list is badge-free, corners are account-scoped,
// and Settings opens Appearance/last active tab unless a valid URL tab wins.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const prefs = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const settings = fs.readFileSync('src/admin/web/settings.js', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const login = fs.readFileSync('src/admin/web/login.js', 'utf8');
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
const tests = fs.readFileSync('src/admin/preferences_tests.rs', 'utf8');
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const styles = ['default', 'square', 'subtle', 'rounded', 'extra'];
const appearance = prefs.slice(prefs.indexOf('function renderDashboardWidgetSettings()'), prefs.indexOf('function refreshChartStylePalette'));
assert(appearance.includes('dashboard-widget-setting-actions'));
assert(!appearance.includes('dashboardWidgetTypeBadge('), 'Appearance rows must not render type badges');
assert(prefs.includes('function dashboardWidgetTypeBadge('), 'visual editors retain type labels');
assert(html.includes('id="ui-corner-style"'));
assert(html.includes('id="dashboard-widget-list"'));
const contract = schema.components.schemas.AdminPreferences.properties.corner_style;
assert.deepEqual(contract.enum, styles);
assert.equal(contract.default, 'default');
assert(rust.includes('default_admin_corner_style'));
assert(rust.includes('"default" | "square" | "subtle" | "rounded" | "extra"'));
assert(tests.includes('fn corner_style_defaults_and_rejects_unknown_presets()'));
for (const key of ['cornerStyle', 'cornerStyleHelp', 'cornerStyleDefault', 'cornerStyleSquare', 'cornerStyleSubtle', 'cornerStyleRounded', 'cornerStyleExtra']) {
assert(en[key] && pl[key], `missing translation ${key}`);
}
assert(!/html:is\(\[data-theme="ultra_dark"\], \[data-theme="cream"\]\) :is\(\.card, \.stat, dialog\) \{/.test(css), 'theme may not override corner geometry');
assert(css.includes('--logo-accent: #f69b74;'));
assert(css.includes('--logo-secondary: #cfe0ac;'));
assert(css.includes('var(--logo-accent, var(--accent))'));
assert(login.includes('rp-corner-style'), 'cached corners must work on login too');
const decl = prefs.match(/^const ADMIN_CORNER_STYLES = .*;$/m)?.[0];
const normalize = prefs.match(/^function normalizeAdminCornerStyle\(value\) \{[^\n]*\}$/m)?.[0];
const body = prefs.split('function applyAdminCornerStyle(value, cache = true) {')[1]?.split('function applyAdminTableDensity(')[0];
assert(decl && normalize && body);
const storage = new Map();
const root = { dataset: {} }, input = { value: '' };
const ctx = { document: { documentElement: root }, $: id => id === 'ui-corner-style' ? input : null,
localStorage: { setItem: (key, value) => storage.set(key, value) } };
vm.createContext(ctx);
vm.runInContext(`${decl}\n${normalize}\nfunction applyAdminCornerStyle(value, cache = true) {${body}`, ctx);
for (const style of styles) {
assert.equal(vm.runInContext(`applyAdminCornerStyle('${style}')`, ctx), style);
assert.equal(root.dataset.cornerStyle, style);
assert.equal(storage.get('rp-corner-style'), style);
assert.equal(input.value, style);
}
vm.runInContext("applyAdminCornerStyle('invalid')", ctx);
assert.equal(root.dataset.cornerStyle, 'default');
assert(prefs.includes('corner_style: "default"'), 'old account defaults preserve original geometry');
assert(prefs.includes('corner_style: next'), 'choice persisted via API');
assert(prefs.includes('applyAdminCornerStyle(previous)'), 'failed update rolls back');
const setup = settings.slice(settings.indexOf(' const buttons ='), settings.indexOf(' const policyTabs ='));
assert(setup.length > 1000);
function boot(path, cached, query = '') {
const cache = new Map(cached ? [['rp-settings-last-tab', cached]] : []);
const names = ['appearance', 'listeners', 'cache', 'security', 'manual-config', 'logging'];
const buttons = names.map(name => ({ dataset: { settingsTab: name }, hidden: false,
textContent: name, addEventListener() {}, setAttribute() {}, focus() {} }));
const pages = names.map(name => ({ dataset: { settingsPage: name }, hidden: true }));
const url = new URL(`https://dashboard.example${path}${query}`);
const location = { href: url.href, pathname: url.pathname, search: url.search, hash: '', protocol: url.protocol };
const context = { location, URL, URLSearchParams, CSS: { escape: value => value },
localStorage: { getItem: key => cache.get(key) || null, setItem: (key, value) => cache.set(key, value) },
document: { querySelectorAll: selector => selector === '[data-settings-tab]' ? buttons : pages,
querySelector: () => null }, $: () => null,
history: { state: {}, replaceState(_state, _title, href) { location.href = 'https://dashboard.example' + href; } },
window: { rustProxyRefresh: { setSettingsTab() {} } }, t: key => key,
};
const vmctx = vm.createContext(context);
const result = vm.runInContext(`(() => { ${setup}\nreturn { current: () => active, select }; })()`, vmctx);
return { ...result, cache };
}
assert.equal(boot('/settings').current(), 'appearance', 'first Settings visit opens Appearance');
assert.equal(boot('/settings', 'cache').current(), 'cache', 'last Settings tab is restored');
assert.equal(boot('/settings', 'unknown').current(), 'appearance', 'invalid cached tab is ignored');
assert.equal(boot('/settings', 'cache', '?tab=security').current(), 'security', 'URL tab takes precedence');
assert.equal(boot('/configuration', 'cache').current(), 'manual-config', 'legacy /configuration keeps intent');
assert.equal(boot('/logging', 'cache').current(), 'logging', 'legacy /logging keeps intent');
const active = boot('/settings');
active.select('cache');
assert.equal(active.cache.get('rp-settings-last-tab'), 'cache');
active.select('security');
assert.equal(active.cache.get('rp-settings-last-tab'), 'security');
active.select('appearance', false, false);
assert.equal(active.cache.get('rp-settings-last-tab'), 'security', 'temporary selection does not override remembered tab');
console.log('PASS: badge-free Appearance, 5 global corner presets, logo colors, account/API defaults, and last Settings tab navigation');
+3 -1
View File
@@ -6,6 +6,7 @@ import vm from 'node:vm';
const root = process.cwd();
const core = fs.readFileSync(`${root}/src/admin/web/vhosts/core.js`, 'utf8');
const protocol = fs.readFileSync(`${root}/src/admin/web/vhosts/protocol.js`, 'utf8');
const editor = fs.readFileSync(`${root}/src/admin/web/vhosts/editor.js`, 'utf8');
const settings = fs.readFileSync(`${root}/src/admin/web/app/settings.js`, 'utf8');
const html = fs.readFileSync(`${root}/src/admin/web/index.html`, 'utf8');
@@ -19,6 +20,7 @@ class Element {
this.tag = tag;
this.value = value == null ? '' : String(value);
this.checked = false;
this.dataset = {};
this.hidden = false;
this.disabled = false;
this.name = '';
@@ -61,7 +63,7 @@ const value = {
interval_ms: 10000, timeout_ms: 1000, slow_response_ms: 500, fall: 2,
rise: 1, expected_status_min: 200, expected_status_max: 399 }
};
vm.runInNewContext(`${core.slice(start, end)}\nmemberRow(parent, value);`, {
vm.runInNewContext(`${protocol}\n${core.slice(start, end)}\nmemberRow(parent, value);`, {
parent, value, el, text, field, button, note,
t: key => key, toast: () => {}, changed: () => {},
appConfirm: async () => true, adminListenerBackendCandidate: () => false, backendHealthEditorControl: null
@@ -0,0 +1,172 @@
// 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(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');
+3 -2
View File
@@ -5,6 +5,7 @@ import fs from 'node:fs';
import vm from 'node:vm';
const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
const protocol = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8');
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
@@ -15,7 +16,7 @@ assert(first >= 0 && last > first, 'Vhost member editor missing');
class Element {
constructor(tag = 'div', value = '', css = '') {
this.tag = tag; this.value = String(value ?? ''); this.textContent = this.value;
this.children = []; this.parent = null; this.name = ''; this.checked = false;
this.children = []; this.parent = null; this.name = ''; this.checked = false; this.dataset = {};
this.disabled = false; this.hidden = false; this.listeners = new Map();
this.classes = new Set(css.split(/\s+/).filter(Boolean));
}
@@ -70,7 +71,7 @@ const context = {
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
"status-min": "201", "status-max": "398" })[name] } : null),
};
const exports = vm.runInNewContext(`${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
const exports = vm.runInNewContext(`${protocol}\n${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
const members = el('div');
const probe = (mode, path) => ({ mode, path, host: null, interval_ms: 5000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, rise: 1, expected_status_min: 200, expected_status_max: 399 });
const a = { id: 'alpha', url: 'http://192.0.2.1:9000', health_check_enabled: true, health_check: probe('http', '/a') };
+2 -2
View File
@@ -7,7 +7,7 @@ import tomllib
root = Path(__file__).resolve().parents[2]
read = lambda path: (root / path).read_text(encoding='utf-8')
version = tomllib.loads(read('Cargo.toml'))['package']['version']
assert version == '0.32.2', version
assert version == '0.32.3', version
spec = json.loads(read('src/admin/openapi.json'))
assert spec['info']['version'] == version
paths = spec['paths']
@@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'):
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
assert strings.get(key), (language, key)
assert '0.32.2' in read('docs/TEST_REPORT.md')
assert '0.32.3' in read('docs/TEST_REPORT.md')
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
+12 -12
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.32.2"
assert manifest["package"]["version"] == "0.32.3"
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
h3dep = manifest["dependencies"]["h3"]
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
lock_path = root / "Cargo.lock"
if lock_path.exists():
lock = lock_path.read_text()
assert 'name = "rust-proxy"\nversion = "0.32.2"' in lock
assert 'name = "rust-proxy"\nversion = "0.32.3"' in lock
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
for release_doc, marker in {
"README.md": "# RustProxy 0.32.2",
"CHANGELOG.md": "## 0.32.2",
"ROADMAP.md": "## 0.32.2",
"AGENTS.md": "Current source version: 0.32.2.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.2",
"README.md": "# RustProxy 0.32.3",
"CHANGELOG.md": "## 0.32.3",
"ROADMAP.md": "## 0.32.3",
"AGENTS.md": "Current source version: 0.32.3.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.3",
}.items():
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
for marker in ['"version": "0.32.2"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
for marker in ['"version": "0.32.3"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
assert marker in openapi, marker
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
@@ -1014,13 +1014,13 @@ assert '`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`' in footer_bloc
assert 'update?.update_available && update.latest_version' in footer_block and 'footer-update' in footer_block
assert '.page-footer .footer-update {' in style_source
assert '.logs-dialog {' in style_source and 'overflow: visible' in style_source
assert '/api/preferences' in app_js and 'live_log_columns' in management_ui and 'ADMIN_THEMES = new Set(["dark", "light"])' in app_js
assert '/api/preferences' in app_js and 'live_log_columns' in management_ui and 'ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"])' in app_js
assert 'id="ui-theme"' in html and 'data-i18n="themeLight"' in html
assert 'html[data-theme="light"] {' in style_source and '--field-bg:' in style_source and '--sidebar-bg:' in style_source
assert 'overflow: visible;' in style_source and 'max-height: min(38dvh, 220px)' not in style_source
assert 'body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"])' in style_source
assert 'input:not([type="checkbox"]):not([type="radio"]),\nselect,\ntextarea {' in style_source
assert '#[serde(default = "default_admin_theme")]' in admin_source and 'matches!(preferences.theme.as_str(), "dark" | "light")' in admin_source
assert '#[serde(default = "default_admin_theme")]' in admin_source and 'matches!(preferences.theme.as_str(), "dark" | "ultra_dark" | "light" | "cream")' in admin_source
# 0.22.3 dashboard/config/cache/log-history regressions.
assert 'bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages")' in management_ui
assert '"pages" => {' in admin_source and 'config.pages =' in admin_source
@@ -1143,7 +1143,7 @@ for marker_0221 in [
'target = makeCopyCell(targetValue)', 'makeCopyCell(keyTags)',
]:
assert marker_0221 in cache_panel_js, marker_0221
assert html.count('class="stats-grid"') >= 1
assert re.search(r'class="[^"]*\bstats-grid\b[^"]*"', html) is not None
assert 'stats-grid transport-stats' not in html and 'transport-websocket' not in html
assert 'transport-action' not in html and '.transport-grid .transport-action' not in style_source
assert '#panel-cache .cache-copy-cell' in style_source and 'cursor: copy' in style_source
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
assert 'location.pathname === "/logging"' in settings_js_0241
assert '"RuntimeInformation"' in openapi and '"version": "0.32.2"' in openapi
assert '"RuntimeInformation"' in openapi and '"version": "0.32.3"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs")
@@ -0,0 +1,235 @@
// Offline simulation of direct Overview arrangement. No authenticated browser
// or Rust networking is exercised by this test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const widgets = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = widgets.map(w => w.id);
const hiddenDefault = widgets.filter(w => w.default_hidden).map(w => w.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-inline-layout.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save',
'overview-inline-cancel', 'overview-inline-hint', 'dashboard-overview-grid'])
assert(html.includes(`id="${id}"`), `missing ${id}`);
class Node {
constructor(tag = 'div', value = '', cls = '') {
this.tag = tag; this.textContent = value ?? ''; this.classes = new Set(cls.split(' ').filter(Boolean));
this.classList = { add: (...names) => names.forEach(name => this.classes.add(name)),
remove: (...names) => names.forEach(name => this.classes.delete(name)) };
this.children = []; this.listeners = new Map(); this.dataset = {};
this.attributes = {}; this.hidden = false; this.disabled = false; this.parent = null;
}
append(...children) {
for (const child of children) {
child.remove(); this.children.push(child); child.parent = this;
}
}
replaceChildren(...children) {
for (const child of this.children) child.parent = null;
this.children = [];
this.append(...children);
}
remove() {
if (this.parent) { const at = this.parent.children.indexOf(this); if (at >= 0) this.parent.children.splice(at, 1); }
this.parent = null;
}
closest(selector) {
for (let cur = this; cur; cur = cur.parent) {
if (selector === '[data-dashboard-widget]' && cur.dataset.dashboardWidget) return cur;
if (selector === '[data-overview-drag-handle]' && cur.dataset.overviewDragHandle) return cur;
}
return null;
}
contains(other) { return !!other && (other === this || this.children.some(child => child.contains(other))); }
querySelector(selector) {
for (const child of this.children) {
if (child.classes.has(selector.slice(1)) || child.tag === selector) return child;
const nested = child.querySelector(selector);
if (nested) return nested;
}
return null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getBoundingClientRect() { return { left: 0, width: 100 }; }
addEventListener(name, cb) { this.listeners.set(name, [...(this.listeners.get(name) || []), cb]); }
fire(name, event = {}) {
const e = { target: this, clientX: 0, prevented: false, preventDefault() { this.prevented = true; }, ...event };
for (const cb of this.listeners.get(name) || []) cb(e);
return e;
}
focus() { this.focused = true; }
}
const controls = new Map(['dashboard-overview-grid', 'overview-layout-unlock',
'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel',
'overview-inline-hint', 'overview-inline-hidden', 'overview-inline-hidden-list',
'overview-inline-hidden-count', 'overview-layout-open', 'overview-layout-dialog'].map(id => [id, new Node()]));
const grid = controls.get('dashboard-overview-grid');
controls.get('overview-inline-hidden').append(new Node('summary', 'Hidden blocks'));
const cards = new Map(ids.map(id => {
const node = new Node('article'); node.dataset.dashboardWidget = id;
node.hidden = hiddenDefault.includes(id);
node.setAttribute('id', `real-${id}`);
node.append(new Node('strong', id));
grid.append(node);
return [id, node];
}));
let current = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault], theme: 'light' };
const writes = [], notifications = [];
let reject = false;
const window = {
rustProxyPreferences: {
load: async () => current,
current: () => current,
update: async patch => {
if (reject) throw Error('server refused');
writes.push(patch);
current = { ...current, ...patch };
applyDashboardLayout(current.dashboard_widgets, current.dashboard_hidden_widgets);
return current;
},
},
};
function applyDashboardLayout(order, hidden) {
for (const id of order) { cards.get(id).hidden = hidden.includes(id); grid.append(cards.get(id)); }
}
const $ = id => controls.get(id);
const ctx = {
window, $, el: (tag, value, cls) => new Node(tag, value, cls),
t: key => `loc:${key}`, toast: msg => notifications.push(msg),
dashboardWidgetLabel: id => `label:${id}`,
dashboardWidgetTypeBadge: id => new Node('span', `kind:${widgets.find(w => w.id === id).display_type}`, 'overview-widget-type'),
applyDashboardLayout,
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault] },
normalizeDashboardWidgets: order => [...new Set([...(Array.isArray(order) ? order.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: hidden => [...new Set((hidden || []).filter(id => ids.includes(id)))],
};
vm.createContext(ctx);
vm.runInContext(source, ctx);
const editor = window.rustProxyInlineLayout;
const refresh = () => new Promise(resolve => setImmediate(resolve));
const visible = () => grid.children.filter(node => !node.hidden).map(node => node.dataset.dashboardWidget);
const toolbarCount = () => grid.children.filter(node => node.querySelector('.overview-inline-toolbar')).length;
const button = (id, cls) => cards.get(id).querySelector(cls);
const grip = id => button(id, '.overview-inline-grip');
const dataTransfer = () => ({ setData(k, value) { this[k] = value; }, effectAllowed: '', dropEffect: '' });
// Opening does not clone cards or trigger network writes. Only the handle drags.
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(editor.isEditing());
assert.equal(controls.get('overview-inline-actions').hidden, false);
assert.equal(toolbarCount(), 62);
assert.equal(cards.get('requests').attributes.id, 'real-requests');
assert.equal(grip('requests').dataset.overviewDragHandle, 'requests');
const accidental = grid.fire('dragstart', { target: cards.get('requests'), dataTransfer: dataTransfer() });
assert(accidental.prevented);
assert.equal(writes.length, 0);
// Hide removes the real block from the current view, not the registry; restore
// works immediately without an API call, and Cancel reverts an uncommitted hide.
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
assert(controls.get('overview-inline-hidden').open);
assert.equal(controls.get('overview-inline-hidden').hidden, false);
assert(controls.get('overview-inline-hidden-list').children.length > hiddenDefault.length);
assert.equal(writes.length, 0);
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:requests'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('requests').hidden, 'restore makes the block visible in the same draft');
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
controls.get('overview-inline-cancel').fire('click');
assert(!cards.get('requests').hidden, 'cancel restores the original visibility');
assert.equal(controls.get('overview-inline-hidden').hidden, true);
assert.equal(writes.length, 0);
controls.get('overview-layout-unlock').fire('click'); await refresh();
const transfer = dataTransfer();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: transfer });
assert.equal(transfer['text/plain'], 'requests');
grid.fire('dragover', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
grid.fire('drop', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
assert.deepEqual(visible().slice(0, 3), ['active','requests','bandwidth']);
assert.equal(writes.length, 0);
button('requests', '.overview-inline-up').fire('click');
assert.deepEqual(visible().slice(0, 3), ['requests','active','bandwidth']);
button('traffic_chart', '.overview-inline-up').fire('click');
assert.equal(writes.length, 0);
controls.get('overview-inline-cancel').fire('click');
assert(!editor.isEditing());
assert.deepEqual(visible(), ids.filter(id => !hiddenDefault.includes(id)));
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 0);
// Saving writes exactly one ordered/hidden patch and retains other preferences.
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('active'), clientX: 90 });
reject = true;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(editor.isEditing(), 'failed PUT preserves the draft');
assert.equal(controls.get('overview-inline-hint').textContent, 'server refused');
assert.equal(writes.length, 0);
reject = false;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 1);
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
assert.deepEqual([...current.dashboard_hidden_widgets], hiddenDefault);
assert.equal(current.theme, 'light');
assert.equal(notifications.length, 1);
// Locking without changes does not write again. Navigation discard and dialog guard.
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(writes.length, 1);
controls.get('overview-layout-dialog').open = true;
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing(), 'do not edit actual cards underneath the open modal');
controls.get('overview-layout-dialog').open = false;
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 0 });
assert.notDeepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
editor.cancel();
assert.deepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
assert.equal(writes.length, 1);
console.log('PASS: direct Overview unlock, handles, move arrows, cancel, one save, retry, lock, navigation and modal guard');
// Hide persists across unlock sessions and the list allows recovery without
// touching unrelated account settings. The same API path handles a large panel.
controls.get('overview-layout-unlock').fire('click'); await refresh();
button('backend_table', '.overview-inline-hide').fire('click');
assert(cards.get('backend_table').hidden);
assert.equal(writes.length, 1);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 2);
assert(current.dashboard_hidden_widgets.includes('backend_table'));
assert(cards.get('backend_table').hidden);
assert.equal(current.theme, 'light');
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:backend_table'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('backend_table').hidden);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3);
assert(!current.dashboard_hidden_widgets.includes('backend_table'));
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
console.log('PASS: inline hide, unsaved cancel, hidden shelf restore and one-request persistence');
// In the real browser the row controller groups cards by footprint. A cross-
// group drop should not appear to move a card into an incompatible-height row.
window.rustProxyMasonry = {groupFor: id => id === 'traffic_chart' ? 1 : 0};
const beforeGroups = [...current.dashboard_widgets];
controls.get('overview-layout-unlock').fire('click'); await refresh();
const beforeCrossDrop = visible();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 50 });
assert.deepEqual(visible(), beforeCrossDrop, 'different-size cards must not switch groups');
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3, 'blocked cross-group move must not write');
assert.deepEqual([...current.dashboard_widgets], beforeGroups);
console.log('PASS: cross-size drops cannot break symmetric grid groups');
@@ -0,0 +1,213 @@
// Browser-independent UI simulation of the actual visual Overview modal.
// This does not replace an authenticated DOM/browser acceptance test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = registry.map(widget => widget.id);
const initialHidden = registry.filter(widget => widget.default_hidden).map(widget => widget.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-layout-editor.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel'])
assert(html.includes(`id="${id}"`), id);
class FakeNode {
constructor(tag = 'div', content = '', classes = '') {
this.tag = tag; this.textContent = String(content ?? ''); this.classes = new Set(classes.split(/\s+/).filter(Boolean));
this.classList = { add: (...args) => args.forEach(x => this.classes.add(x)), remove: (...args) => args.forEach(x => this.classes.delete(x)) };
this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = new Map();
this.hidden = false; this.open = false; this.disabled = false; this.value = ''; this.draggable = false;
}
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
addEventListener(name, cb) { const list = this.listeners.get(name) || []; list.push(cb); this.listeners.set(name, list); }
fire(name, event = {}) { for (const cb of this.listeners.get(name) || []) cb({ preventDefault() {}, target: this, ...event }); }
setAttribute(k, v) { this.attrs[k] = String(v); }
removeAttribute(k) { delete this.attrs[k]; }
focus() { this.focused = true; }
showModal() { this.open = true; }
close() { this.open = false; this.fire('close'); }
querySelectorAll(selector) {
const nodes = [];
function visit(node) {
for (const child of node.children) {
if (selector === '[id]' && child.attrs.id) nodes.push(child);
else if (selector === '[aria-labelledby], [aria-describedby]' && (child.attrs['aria-labelledby'] || child.attrs['aria-describedby'])) nodes.push(child);
else if (selector === '.overview-layout-tile' && child.classes.has('overview-layout-tile')) nodes.push(child);
visit(child);
}
}
visit(this); return nodes;
}
querySelector(selector) {
return this.querySelectorAll('.overview-layout-tile').find(node => node.classes.has(selector.slice(1))) ||
(() => { const visited = [this]; while (visited.length) { const node = visited.shift(); if (node.classes.has(selector.slice(1))) return node; visited.push(...node.children); } return null; })();
}
cloneNode(deep = false) {
const copy = new FakeNode(this.tag, this.textContent, [...this.classes].join(' '));
copy.hidden = this.hidden; copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
if (deep) copy.children = this.children.map(node => node.cloneNode(true));
return copy;
}
}
const controls = Object.fromEntries([
'overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden',
'overview-layout-save', 'overview-layout-cancel', 'overview-layout-search', 'overview-layout-type-filter',
'overview-layout-message', 'overview-layout-open', 'overview-layout-open-settings',
'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel',
].map(id => [id, new FakeNode()]));
const originals = new Map(ids.map(id => {
const node = new FakeNode('article', 'metric value', 'stat');
node.attrs.id = `real-${id}`; node.dataset.dashboardWidget = id;
node.append(new FakeNode('strong', '42'));
node.children[0].attrs.id = `metric-${id}`;
node.hidden = initialHidden.includes(id);
return [id, node];
}));
let saved = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden], theme: 'light' };
const writes = [], notifications = [];
let failSave = false;
const window = { rustProxyPreferences: {
current: () => saved,
load: async () => saved,
update: async patch => {
if (failSave) throw Error('server refused');
writes.push(patch); saved = { ...saved, ...patch };
for (const id of ids) originals.get(id).hidden = patch.dashboard_hidden_widgets.includes(id);
return saved;
},
} };
const $ = id => controls[id];
const ctx = {
window, $, t: text => `loc:${text}`, toast: text => notifications.push(text),
el: (tag, content, cls) => new FakeNode(tag, content, cls),
ADMIN_DASHBOARD_BY_ID: new Map(registry.map(widget => [widget.id, widget])),
ADMIN_DASHBOARD_WIDGETS: [...ids],
ADMIN_DEFAULT_HIDDEN_WIDGETS: [...initialHidden],
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden] },
normalizeDashboardWidgets: value => [...new Set([...(Array.isArray(value) ? value.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: value => [...new Set((Array.isArray(value) ? value : []).filter(id => ids.includes(id)))],
dashboardWidgetType: id => registry.find(widget => widget.id === id)?.display_type,
dashboardWidgetTypeLabel: id => `loc:overviewBlockType${({
value: 'Value', value_chart: 'ValueChart', chart: 'Chart', panel: 'Panel', table: 'Table',
})[registry.find(widget => widget.id === id)?.display_type]}`,
dashboardWidgetTypeBadge: id => new FakeNode('span', `type:${registry.find(widget => widget.id === id)?.display_type}`, 'overview-widget-type'),
document: { querySelector: selector => originals.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
};
vm.createContext(ctx); vm.runInContext(source, ctx);
const module = window.rustProxyDashboardLayout;
const dialog = controls['overview-layout-dialog'];
const visible = controls['overview-layout-visible'];
const hidden = controls['overview-layout-hidden'];
const refresh = () => new Promise(resolve => setImmediate(resolve));
const previewIds = () => visible.children.filter(node => node.dataset.previewWidget).map(node => node.dataset.previewWidget);
// Cancel changes, no PUT or change of real dashboard.
controls['overview-layout-open'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(previewIds().length, ids.length - initialHidden.length);
assert.equal(hidden.children.length, initialHidden.length);
assert.equal(visible.children[0].children[0].children[1].textContent, 'type:value');
assert.equal(hidden.children[0].children[1].textContent, `type:${registry.find(w => w.id === initialHidden[0]).display_type}`);
controls['overview-layout-type-filter'].value = 'chart';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.filter(item => item.classes.has('overview-layout-hidden-item')).length, registry.filter(w => w.default_hidden && w.display_type === 'chart').length, 'type filter shows only hidden charts');
controls['overview-layout-type-filter'].value = 'all';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.length, initialHidden.length);
const previewCopy = visible.children[0].children[1];
assert.equal(previewCopy.attrs.id, undefined, 'preview clones must not duplicate real IDs');
assert.equal(previewCopy.children[0].attrs.id, undefined);
assert.equal(previewCopy.attrs['aria-hidden'], 'true');
const first = previewIds()[0];
visible.children[0].children[0].children[2].children[2].fire('click');
assert(!previewIds().includes(first));
assert.equal(originals.get(first).hidden, false, 'draft hide must not touch live cards');
assert.equal(writes.length, 0);
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
assert.equal(writes.length, 0);
// Settings and Overview entry points are wired to the same editor.
controls['overview-layout-open-settings'].fire('click'); await refresh();
assert.equal(dialog.open, true);
const originalVisible = previewIds();
const from = visible.children[0], to = visible.children[2];
const dataTransfer = { setData(type, value) { this[type] = value; }, effectAllowed: '', dropEffect: '' };
from.fire('dragstart', { target: from, dataTransfer });
to.fire('dragover', { dataTransfer });
to.fire('drop', { dataTransfer });
assert.equal(previewIds()[2], originalVisible[0], 'dropping moves a tile after the drop target');
assert.equal(previewIds()[0], originalVisible[1]);
// Keyboard buttons move cards back, without rebuilding saved prefs prematurely.
visible.children[2].children[0].children[2].children[0].fire('click');
assert.equal(previewIds()[1], originalVisible[0]);
const firstHiddenId = initialHidden[0];
const hiddenItem = hidden.children.find(node => node.children[0].textContent === `loc:${registry.find(x => x.id === firstHiddenId).label_key}`);
hiddenItem.children[2].fire('click');
assert(previewIds().includes(firstHiddenId));
assert.equal(writes.length, 0);
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(writes.length, 1, 'one atomic preferences update');
assert.equal(dialog.open, false);
assert.equal(saved.theme, 'light', 'unrelated preferences are untouched');
assert.equal(new Set(saved.dashboard_widgets).size, ids.length);
assert.equal(saved.dashboard_widgets.length, ids.length);
assert(!saved.dashboard_hidden_widgets.includes(firstHiddenId));
assert.equal(notifications.length, 1);
// Failed save keeps the dialog open and the unsaved draft available for retry.
controls['overview-layout-open'].fire('click'); await refresh();
failSave = true;
const writeCount = writes.length;
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(controls['overview-layout-message'].textContent, 'server refused');
assert.equal(writes.length, writeCount);
failSave = false;
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
// Default view is only a draft until Save: no implicit writes.
controls['overview-layout-open'].fire('click'); await refresh();
const writesBeforeDefault = writes.length;
controls['overview-layout-default'].fire('click');
assert.deepEqual(previewIds(), ids.filter(id => !initialHidden.includes(id)));
assert(controls['overview-layout-message'].textContent.includes('overviewLayoutDefaultLoaded'));
assert.equal(writes.length, writesBeforeDefault);
assert(previewIds().includes('backend_table'), 'all seven full-width Overview panels belong to the editor');
assert.equal(visible.children.find(node => node.dataset.previewWidget === 'backend_table').classes.has('is-panel'), true);
// Reset first prompts for confirmation, cancellation must not change preferences.
controls['overview-layout-reset'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
controls['overview-reset-cancel'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(writes.length, writesBeforeDefault);
// A failed reset keeps the confirmation open and never claims success.
controls['overview-layout-reset'].fire('click'); failSave = true;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
failSave = false;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(dialog.open, false);
assert.equal(writes.length, writesBeforeDefault + 1);
assert.deepEqual([...saved.dashboard_widgets], ids);
assert.deepEqual([...saved.dashboard_hidden_widgets], initialHidden);
assert.equal(saved.theme, 'light', 'reset must not affect unrelated account preferences');
console.log('PASS: full Overview editor, drag/keyboard, legacy settings, default preview, confirmed reset, cancel, atomic save and failures');
// Group boundaries are a UX constraint in the real browser: the modal must
// not let a small tile replace the position of a tall diagnostic panel.
window.rustProxyMasonry = { groupFor: id => id === 'traffic_chart' ? 1 : id.endsWith('_diagnostics') ? 3 : 0 };
controls['overview-layout-open'].fire('click'); await refresh();
const beforeGroupMove = previewIds();
const small = visible.children.find(node => node.dataset.previewWidget === 'requests');
const big = visible.children.find(node => node.dataset.previewWidget === 'traffic_chart');
small.fire('dragstart', { target: small, dataTransfer: {setData() {},effectAllowed:''} });
big.fire('drop');
assert.deepEqual(previewIds(), beforeGroupMove, 'the modal must not mix incompatible tile groups');
controls['overview-layout-cancel'].fire('click');
console.log('PASS: visual editor respects symmetric height groups');
+1 -1
View File
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
args = parser.parse_args()
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
assert manifest['package']['version'] == '0.32.2'
assert manifest['package']['version'] == '0.32.3'
assert manifest['package']['rust-version'] == '1.99'
deps = manifest['dependencies']
expected_requirements = {
+1 -1
View File
@@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
assert readme.startswith("# RustProxy 0.32.2")
assert readme.startswith("# RustProxy 0.32.3")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
+1 -1
View File
@@ -23,6 +23,6 @@ assert.match(js, /aria-selected/);
assert.match(js, /\$\("file-log-view-" \+ name\)\.hidden = !selected/);
assert.match(js, /activeView === "counts" && aggregateField/);
assert.match(js, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
assert.match(css, /#file-log-view-counts\[hidden\], #file-log-view-entries\[hidden\]/);
assert.match(css, /#file-log-view-counts\[hidden\]\s*,\s*#file-log-view-entries\[hidden\]/);
for (const strings of [en, pl]) for (const key of ['fileLogsConsolidateShort', 'fileLogsEntriesTab','fileLogsCountsTab']) assert.ok(strings[key], key);
console.log('PASS: Stored Logs separate tabs, shared filter, compact persisted consolidation toggle, localized UI.');
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
html = (ROOT / 'src/admin/web/index.html').read_text()
assert '0.32.2' in (ROOT / "Cargo.toml").read_text()
assert '0.32.3' in (ROOT / "Cargo.toml").read_text()
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
@@ -43,10 +43,14 @@ assert.match(caption, /font-size:\s*10px/);
assert.match(payload, /font-size:\s*10px\s*!important/);
assert.match(css, /#panel-overview \.stats-grid \.stat-chart>\.stat-chart-legend\s*\{[^}]*flex-wrap:\s*wrap/s);
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('class="overview-grid"'));
assert.equal([...overview.matchAll(/data-dashboard-widget="/g)].length, 16,
'no metric/Stream cards may be removed');
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"'));
const widgets = [...overview.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(match => match[1]);
assert.equal(widgets.length, 62, '49 existing widgets plus 6 small and 7 large opt-in charts');
assert.equal(widgets.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_') && !id.startsWith('wide_') && !['traffic_chart','system_chart','connection_chart','cache_diagnostics','process_diagnostics','network_diagnostics','backend_table'].includes(id)).length, 16,
'all 16 pre-existing metric/Stream cards must be retained');
assert.match(overview, /id="metric-bytes-split"/);
assert.match(overview, /id="http-protocol-chart"/);
assert.match(overview, /id="ws-protocol-chart"/);
assert.match(overview, /id="dashboard-overview-grid"/);
assert.doesNotMatch(overview, /class="overview-grid"|class="diagnostics-grid"/, 'all Overview widgets must share one arrangement grid');
console.log('PASS: 0.32.1 Overview six-column content height, 90% compact typography, shared tile spacing and inline legends.');
@@ -0,0 +1,95 @@
// Existing status snapshots only: no API calls, bounded histories and honest no-data.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/app/overview-extra-metrics.js', 'utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = registry.filter(widget => widget.category === 'performance').map(widget => widget.id);
assert.equal(ids.length, 18);
const widgets = new Map(ids.map(id => [id, { hidden: false }]));
const fields = new Map();
const field = id => {
if (!fields.has(id)) fields.set(id, { textContent: '-', points: '', setAttribute(name, value) { this[name] = value; } });
return fields.get(id);
};
let now = 1000;
const context = {
window: {}, currentPanel: 'overview', performance: { now: () => now },
t: key => key, fmt: (v, places = 0) => Number(v).toFixed(places),
fmtBytes: v => `${v} B`, $: field,
document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
};
vm.createContext(context);
vm.runInContext(source, context);
const render = context.window.rustProxyExtraOverview.render;
const text = id => field(`overview-extra-${id}`).textContent;
const points = id => field(`overview-extra-${id}-line`).points;
const start = {
metrics: { requests: 100, http1_requests: 70, http2_requests: 30, http3_requests: 0, tls_handshake_errors: 3, errors: 10, active_connections: 5, active: 2, websocket_active: 1, mean_duration_ms: 25 },
system: { process_cpu_percent: 12, process_rss_bytes: 1000, open_fds: 10, max_fds: 500 },
cache: { hits: 10, misses: 10, resident_bytes: 10, hot_limit_bytes: 100 },
backend_pools: [{ members: [{status: 'up'}, {status: 'down'}] }],
};
render(start);
assert.equal(text('extra_process_cpu'), '12.0%');
assert.equal(text('extra_process_rss'), '1000 B');
assert.equal(text('extra_backend_unhealthy'), '1');
for (const id of ['extra_tls_error_rate','extra_request_error_rate','extra_cache_hit_rate','extra_h2_share'])
assert.equal(text(id), '-', `initial ${id} delta must not be invented`);
now = 3000;
const next = structuredClone(start);
next.metrics = { ...start.metrics, requests: 130, http1_requests: 84, http2_requests: 45, http3_requests: 1, tls_handshake_errors: 5, errors: 16, active_connections: 6, active: 4, websocket_active: 3, mean_duration_ms: 40 };
next.system = { ...start.system, process_cpu_percent: 22, process_rss_bytes: 2000, open_fds: 20 };
next.cache = { ...start.cache, hits: 20, misses: 15, resident_bytes: 25 };
next.backend_pools[0].members[0].status = 'down';
render(next);
assert.equal(text('extra_tls_error_rate'), '1.0/s');
assert.equal(text('extra_request_error_rate'), '3.0/s');
assert.equal(text('extra_cache_hit_rate'), '66.7%');
assert.equal(text('extra_h2_share'), '50.0%');
assert.equal(text('extra_cache_memory'), '25.0%');
assert.equal(text('extra_response_latency'), '40.0 ms');
assert.equal(text('extra_backend_unhealthy'), '2');
assert.equal(text('extra_total_rps'), '15.0/s');
assert.equal(text('extra_active_exchanges'), '4');
assert.equal(text('extra_h1_share'), '46.7%');
assert.equal(text('extra_h3_share'), '3.3%');
assert.equal(text('extra_cache_miss_rate'), '2.5/s');
assert.equal(text('extra_backend_active'), '0');
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 2);
// Hiding a chart stops sampling; stale cumulative counters are never used as an instant rate.
widgets.get('extra_tls_error_rate').hidden = true;
now = 5000;
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 50 } });
assert.equal(text('extra_tls_error_rate'), '1.0/s');
widgets.get('extra_tls_error_rate').hidden = false;
now = 6000;
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 60 } });
assert.equal(text('extra_tls_error_rate'), '-', 'first fresh visible sample establishes a new baseline');
// After a reset, don't return a negative rate or denominator percentage.
now = 7000;
render({ ...next, metrics: { ...next.metrics, requests: 1, http2_requests: 0, tls_handshake_errors: 0, errors: 0 }, cache: { ...next.cache, hits: 0, misses: 0 } });
assert.equal(text('extra_h2_share'), '-');
assert.equal(text('extra_request_error_rate'), '-');
assert.equal(text('extra_tls_error_rate'), '-');
// Explicit unavailability is not represented as a measured zero.
now = 8000;
render({ metrics: {}, system: {}, cache: {} });
assert.equal(text('extra_process_cpu'), '-');
assert.equal(text('extra_websocket_active'), '-');
assert.equal(text('extra_backend_unhealthy'), '-');
context.window.rustProxyExtraOverview.reset();
now = 9000;
for (let index = 0; index < 50; index++) {
now += 1000;
render({ ...next, system: { ...next.system, process_cpu_percent: index } });
}
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 30, 'bounded history');
context.currentPanel = 'vhosts';
const pointBefore = points('extra_process_cpu');
now += 1000;
render(next);
assert.equal(points('extra_process_cpu'), pointBefore, 'no extra sampling outside Overview');
assert.match(source, /if \(!tile \|\| tile.hidden\)/);
console.log('PASS: 18 extra metric series, time-based deltas, no-data, counter resets, hidden opt-ins and 30-sample bounds');
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Overview row symmetry regression using the shipped CSS/JS and simulated status.
Browser DOM geometry only; does not exercise the running RustProxy backend.
"""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2] / 'src/admin/web'
soup = BeautifulSoup((ROOT / 'index.html').read_text(), 'html.parser')
widgets = list(soup.select('#dashboard-overview-grid > [data-dashboard-widget]'))
assert len(widgets) == 62
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
'<head><meta charset="utf-8"><style>' + (ROOT / 'style.css').read_text() +
'</style></head><body style="margin:0"><div id="test-shell" '
'style="padding:8px;box-sizing:border-box"><div class="brand">'
'<div class="brand-mark" id="logo-test">R<span>P</span></div></div>'
'<section id="panel-overview"><div id="dashboard-overview-grid" '
'class="stats-grid dashboard-overview-grid">' +
''.join(str(widget) for widget in widgets) + '</div></section></div></body></html>')
SNAPSHOT = """() => {
const grid = document.getElementById('dashboard-overview-grid');
const style = getComputedStyle(grid), outer = grid.getBoundingClientRect();
const group = window.rustProxyMasonry.groupFor;
return {
packed: grid.classList.contains('is-packed'),
gap: parseFloat(style.columnGap),
columns: style.gridTemplateColumns.trim().split(/\\s+/).length,
grid: {left:outer.left,right:outer.right,width:outer.width},
scroll: grid.scrollWidth, client: grid.clientWidth,
cards: [...grid.children].filter(node=>node.dataset.dashboardWidget && !node.hidden).map(node=>{
const r=node.getBoundingClientRect();return {id:node.dataset.dashboardWidget,
group:group(node.dataset.dashboardWidget),x:r.left,y:r.top,right:r.right,
bottom:r.bottom,width:r.width,height:r.height};
})
};
}"""
def validate(data, zoom, width):
"""Peers in each row must have one top edge and one bottom edge."""
cards = data['cards']; gap = data['gap'] * zoom
assert cards and not data['packed'], (width, 'absolute/masonry mode is forbidden')
assert data['scroll'] <= data['client'] + 2, (width, 'horizontal overflow')
seen_groups = []
rows = {}
for card in cards:
assert card['width'] > 40 and card['height'] > 26, (width, card)
assert card['x'] >= data['grid']['left'] - 1, (width, card)
assert card['right'] <= data['grid']['right'] + 1, (width, card)
if not seen_groups or seen_groups[-1] != card['group']:
assert card['group'] not in seen_groups, (width, 'noncontiguous size groups')
seen_groups.append(card['group'])
row = round(card['y'], 1)
rows.setdefault(row, []).append(card)
for members in rows.values():
assert len({m['group'] for m in members}) == 1, (width, 'mixed sizes in a row', members)
assert max(m['bottom'] for m in members) - min(m['bottom'] for m in members) <= 1.5, (width, 'ragged bottom', members)
assert max(m['height'] for m in members) - min(m['height'] for m in members) <= 1.5, (width, 'uneven heights', members)
for a,b in zip(members,members[1:]):
assert abs(b['x']-a['right']-gap) <= 2.0, (width,'wrong horizontal gutter',a,b,gap)
line_groups = list(rows.values())
for above, below in zip(line_groups,line_groups[1:]):
a = above[0]; b=below[0]
assert b['y'] >= max(v['bottom'] for v in above) + gap - 2, (width,'vertical gap',a,b)
return rows
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True,
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
args=['--no-sandbox'])
page = browser.new_page(viewport={'width':1720,'height':950})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=(ROOT / 'app/overview-masonry.js').read_text())
page.evaluate("""() => window._overviewOriginal = [...document.querySelectorAll(
'#dashboard-overview-grid > [data-dashboard-widget]')].map(n=>[n.dataset.dashboardWidget,n.hidden])""")
data=page.evaluate(SNAPSHOT)
assert data['columns']==12
assert [card['id'] for card in data['cards'][:6]] == [
'requests','active','bandwidth','errors','cache','memory']
validate(data,1,1720)
assert len({round(v['height']) for v in data['cards'][:6]}) == 1
page.locator('#dashboard-overview-grid').screenshot(
path=str(Path(__file__).parents[3] / 'overview_uniform_default_preview.png'))
# Screenshot regression: custom ordering previously produced 3 distinctly
# sized tiles on the same horizontal band (a metric + two tall charts).
ids=['backend_connection_errors','process_diagnostics','network_diagnostics','cache_diagnostics',
'connection_chart','traffic_chart','system_chart','backend_table',
'requests','active','http_protocol','websocket_protocol',
'wide_cpu','wide_memory']
page.evaluate("""ids => {
const grid=document.getElementById('dashboard-overview-grid');
for(const node of grid.children) if(node.dataset.dashboardWidget)
node.hidden=!ids.includes(node.dataset.dashboardWidget);
grid.prepend(...ids.map(id=>grid.querySelector(`[data-dashboard-widget="${id}"]`)));
window.rustProxyMasonry.refresh();
}""", ids)
changed=page.evaluate(SNAPSHOT)
validate(changed,1,1720)
assert [card['group'] for card in changed['cards']] == sorted(card['group'] for card in changed['cards'])
assert not any(card['id']=='backend_connection_errors' and card['y'] == next(v['y'] for v in changed['cards'] if v['id']=='process_diagnostics') for card in changed['cards'])
# 3 uniform diagnostic panels per desktop row, 2 large charts per row.
panels=[card for card in changed['cards'] if card['group']==3]
assert len({round(card['y']) for card in panels[:3]}) == 1
assert len({round(card['height']) for card in panels[:3]}) == 1
wides=[card for card in changed['cards'] if card['group']==2]
assert len(wides)==2 and abs(wides[0]['height']-wides[1]['height']) < 2
page.screenshot(path=str(Path(__file__).parents[3] / 'overview_uniform_preview.png'), full_page=True)
# Runtime content growth automatically expands all peers in its Grid row,
# without ResizeObserver, manual coordinates or animation loops.
old_height=panels[0]['height']
page.evaluate("""() => {
const panel=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const child=document.createElement('div');child.id='expanded';child.style.height='175px';panel.append(child);
}""")
updated=page.evaluate(SNAPSHOT)
validate(updated,1,1720)
assert next(card for card in updated['cards'] if card['id']=='process_diagnostics')['height'] > old_height + 80
page.evaluate("() => document.getElementById('expanded').remove()")
validate(page.evaluate(SNAPSHOT),1,1720)
radii=page.evaluate("""() => {
const node=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const bar=document.createElement('div');bar.className='overview-inline-toolbar';
bar.innerHTML='<button class="quiet overview-inline-up">←</button><button class="quiet overview-inline-hide">×</button>';
node.append(bar);const out=[];
for(const preset of ['default','square','subtle','rounded','extra']) {
document.documentElement.dataset.cornerStyle=preset;
out.push([getComputedStyle(document.getElementById('logo-test')).borderTopLeftRadius,
getComputedStyle(bar.querySelector('.overview-inline-up')).borderTopLeftRadius,
getComputedStyle(bar.querySelector('.overview-inline-hide')).borderTopLeftRadius]);
}bar.remove();return out;
}""")
assert len({item[0] for item in radii})==1
assert all(item[1]==item[2] for item in radii)
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
for(const node of grid.children) if(node.dataset.dashboardWidget) node.hidden=false;
window.rustProxyMasonry.refresh();
}""")
cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12),
(1200,260,1,'normal',6),(900,0,1,'compact',6),
(900,190,1,'normal',6),(720,190,1,'ultra',2),
(620,0,1,'compact',2),(430,0,1,'ultra',1),
(1140,200,.9,'compact',6),(1140,200,1.1,'normal',6),
(1140,200,1.3,'normal',6)]
for width,sidebar,zoom,density,max_columns in cases:
page.set_viewport_size({'width':width,'height':950})
page.evaluate("""({sidebar,zoom,density})=>{
const shell=document.getElementById('test-shell');
shell.style.width=`${(innerWidth-sidebar)/zoom}px`;
shell.style.marginLeft=`${sidebar}px`;shell.style.zoom=String(zoom);
document.documentElement.dataset.dashboardDensity=density;
window.rustProxyMasonry.refresh();
}""", {'sidebar':sidebar,'zoom':zoom,'density':density})
result=page.evaluate(SNAPSHOT)
assert len(result['cards'])==62
assert result['columns']<=max_columns, (width, result['columns'], max_columns)
validate(result,zoom,width)
print(f"PASS: {width}px, zoom={zoom}, groups={len(set(c['group'] for c in result['cards']))}, "
f"columns={result['columns']}, gap={result['gap']}px")
# Unlock mode adds editing toolbars/padding; rows must remain uniform.
page.set_viewport_size({'width':900,'height':950})
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
const shell=document.getElementById('test-shell');
shell.style.width='auto';shell.style.zoom='1';shell.style.marginLeft='0';
grid.classList.add('is-unlocked');
for(const node of grid.children) if(node.dataset.dashboardWidget) {
const toolbar=document.createElement('div');toolbar.className='overview-inline-toolbar';
toolbar.innerHTML='<button class="quiet overview-inline-grip">⠿</button>'+
'<span class="overview-widget-type">Wykres</span>'+
'<button class="quiet overview-inline-up">←</button>'+
'<button class="quiet overview-inline-down">→</button>'+
'<button class="quiet overview-inline-hide">×</button>';
node.append(toolbar);
}
}""")
validate(page.evaluate(SNAPSHOT),1,900)
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
grid.classList.remove('is-unlocked');
grid.querySelectorAll('.overview-inline-toolbar').forEach(toolbar=>toolbar.remove());
}""")
page.set_viewport_size({'width':1720,'height':950})
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid'),shell=document.getElementById('test-shell');
shell.style.width='auto';shell.style.marginLeft='0';shell.style.zoom='1';
document.documentElement.dataset.dashboardDensity='compact';
grid.append(...window._overviewOriginal.map(([id,hidden])=>{
const node=grid.querySelector(`[data-dashboard-widget="${id}"]`);
node.hidden=hidden;return node;
}));window.rustProxyMasonry.refresh();
}""")
final=page.evaluate(SNAPSHOT)
validate(final,1,1720)
assert [node['id'] for node in final['cards'][:6]]==['requests','active','bandwidth','errors','cache','memory']
browser.close()
print('PASS: 62 cards in aligned rows; no floating heights, safe resize and zoom')
@@ -0,0 +1,72 @@
// Regression for shared server/browser Overview Cards metadata and future opt-in widgets.
import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
const prefs = fs.readFileSync('src/admin/web/app/preferences.js','utf8');
const constants = prefs.slice(prefs.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefs.indexOf('const CHART_STYLE_KEYS'));
const normalize = prefs.slice(prefs.indexOf('function normalizeDashboardWidgets'),prefs.indexOf('function browserLanguage'));
const label = prefs.slice(prefs.indexOf('function dashboardWidgetLabel'),prefs.indexOf('function renderDashboardWidgetSettings'));
assert(constants.startsWith('const ADMIN_DASHBOARD_WIDGETS'));
assert(normalize.startsWith('function normalizeDashboardWidgets'));
assert(label.startsWith('function dashboardWidgetLabel'));
function client(definitions) {
const context = {t: key => `loc:${key}`};
vm.createContext(context);
vm.runInContext(`const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${JSON.stringify(definitions)});\n${constants}\n${normalize}\n${label}\n`,context);
return {
order: value => [...vm.runInContext(`normalizeDashboardWidgets(${JSON.stringify(value)})`,context)],
hidden: (value,saved) => [...vm.runInContext(`normalizeHiddenDashboardWidgets(${JSON.stringify(value)},${JSON.stringify(saved)})`,context)],
label: id => vm.runInContext(`dashboardWidgetLabel(${JSON.stringify(id)})`,context),
};
}
const original = client(registry);
const previous = registry.filter(widget => widget.category === 'overview').map(widget => widget.id);
assert.deepEqual(original.order(previous),registry.map(widget => widget.id));
assert.deepEqual(original.hidden([],previous),registry.filter(widget=>widget.default_hidden).map(widget=>widget.id));
assert.equal(original.label('stream_overview'),'loc:streamWidgetOverview');
assert.equal(original.label('backend_health'),'loc:backendWidgetHealth');
assert.equal(original.label('backend_table'),'loc:vhHealthTitle');
assert.equal(registry.length, 62);
const types = new Set(['value', 'value_chart', 'chart', 'panel', 'table']);
assert(registry.every(widget => types.has(widget.display_type)), 'Every Overview block needs explicit type metadata');
assert.equal(registry.find(w => w.id === 'requests').display_type, 'value');
assert.equal(registry.find(w => w.id === 'backend_requests').display_type, 'value_chart');
assert.equal(registry.find(w => w.id === 'traffic_chart').display_type, 'chart');
assert.equal(registry.find(w => w.id === 'cache_diagnostics').display_type, 'panel');
assert.equal(registry.find(w => w.id === 'backend_table').display_type, 'table');
// Upgrading from the previous 30-widget 0.32.3 archive keeps its exact
// visible/order preferences: only twelve new metrics are opt-in, while
// seven existing large panels remain visible in the factory layout.
const previouslySaved = registry.slice(0, 30).map(widget => widget.id);
const migratedOrder = original.order(previouslySaved);
const migratedHidden = original.hidden(registry.slice(0, 30).filter(w => w.default_hidden).map(w => w.id), previouslySaved);
assert.deepEqual(migratedOrder.slice(0, 30), previouslySaved);
assert.deepEqual(migratedOrder.slice(30), registry.slice(30).map(widget => widget.id));
for (const widget of registry.filter(widget => widget.category === 'performance'))
assert(migratedHidden.includes(widget.id), `${widget.id} must be opt-in after upgrade`);
for (const widget of registry.filter(widget => widget.category === 'panels' && !widget.default_hidden))
assert(!migratedHidden.includes(widget.id), `${widget.id} must retain default visibility`);
for (const widget of registry.filter(widget => widget.id.startsWith('wide_')))
assert(migratedHidden.includes(widget.id), `${widget.id} is a new opt-in chart`);
assert.equal(registry.filter(widget => widget.category === 'panels').length, 14);
for (const widget of registry.filter(widget => widget.category === 'performance')) assert(widget.default_hidden);
for (const id of ['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'])
assert(original.hidden([],previous).includes(id), `${id} must remain opt-in for older preferences`);
assert.deepEqual(original.order(['errors','unknown-0.33.0','requests','errors']).slice(0,2),['errors','requests']);
assert(original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('http3'));
assert(!original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('unknown-0.33.0'));
// Adding a new registry card, with an existing UI element and translation key,
// automatically offers it in the ordered preference list and hides it by default.
const extra={...registry[0], id:'future_upstream', label_key:'futureUpstreamProtocol',
description_key:null, element_selector:'[data-dashboard-widget="future_upstream"]', default_hidden:true};
const expanded=client([...registry,extra]);
assert.equal(expanded.order(previous).at(-1),'future_upstream');
assert(expanded.hidden([],previous).includes('future_upstream'));
assert.equal(expanded.label('future_upstream'),'loc:futureUpstreamProtocol');
assert(!expanded.hidden([],registry.map(widget=>widget.id).concat('future_upstream')).includes('future_upstream'),
'explicitly opted-in widget must not become hidden after a later release');
console.log(`PASS: shared widget registry UI preserves order, visibility and ${registry.length} existing cards`);
@@ -0,0 +1,15 @@
// Source-level guard against reintroducing broken absolute/masonry layout.
import fs from 'node:fs';
import assert from 'node:assert/strict';
const source = fs.readFileSync('src/admin/web/app/overview-masonry.js','utf8');
const css = fs.readFileSync('src/admin/web/style.css','utf8');
assert.match(source,/\.sort\(\(a, b\) => group\(a\.node\) - group\(b\.node\)/);
assert.match(source,/grid\.classList\.remove\("is-packed"\)/);
assert.match(source,/node\.classList\.add\("overview-row-start"\)/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
assert(!/style\.position\s*=\s*["']absolute["']|requestAnimationFrame\(|new ResizeObserver\(/.test(source),
'No absolute positioning, animation loop or ResizeObserver should manage Overview geometry');
assert(!/\.dashboard-overview-grid\.is-aligned\.is-packed\s*\{/.test(css));
assert(!source.includes('fetch(') && !source.includes('setInterval('));
console.log('PASS: regular symmetric CSS Grid for all 62 Overview blocks');
@@ -0,0 +1,75 @@
// Optional large Overview charts: real status schema, counter resets, no polling.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/app/overview-wide-metrics.js','utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
const ids = registry.filter(w => w.id.startsWith('wide_')).map(w => w.id);
assert.equal(ids.length, 7);
assert(ids.every(id => registry.find(w => w.id === id).default_hidden));
const nodes = new Map(ids.map(id => [id,{ hidden: false }]));
const fields = new Map();
function $(id) {
if (!fields.has(id)) fields.set(id,{ textContent:'-', d:'', setAttribute(name,value) {this[name]=value;} });
return fields.get(id);
}
let now = 1000;
const ctx = {window:{},currentPanel:'overview',performance:{now:()=>now},fmt:(n,places=0)=>Number(n).toFixed(places),fmtBytes:n=>`${n} B`,$,
document:{querySelector:selector=>nodes.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1])||null}};
vm.createContext(ctx);vm.runInContext(source,ctx);
const render=ctx.window.rustProxyWideOverview.render;
const text=id=>$(id).textContent;
const baseline = { revision:1,
metrics:{ http1_requests:50,http2_requests:30,http3_requests:20,active_connections:9,websocket_active:3,mean_duration_ms:15 },
system:{cpu_percent:45,process_cpu_percent:16,process_rss_bytes:1500},
cache:{hits:20,misses:10,resident_bytes:220},
backend_pools:[{members:[{status:'up',active:1,last_probe_ms:100,latency_ms:12,health_mode:'active'},{status:'down',active:0}]}]};
render(baseline);
assert.equal(text('overview-wide-wide_protocol_rates-summary'),'-');
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%');
assert.equal(text('overview-wide-wide_memory-process-value'),'1500 B');
assert.equal(text('overview-wide-wide_backend_health-down-value'),'1');
assert.equal(text('overview-wide-wide_latency-probe-value'),'12.0 ms');
now = 3000;
const second=structuredClone(baseline);
second.metrics.http1_requests=70; second.metrics.http2_requests=40; second.metrics.http3_requests=22;
second.cache.hits=30; second.cache.misses=18;
render(second);
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'10.0/s');
assert.equal(text('overview-wide-wide_protocol_rates-h2-value'),'5.0/s');
assert.equal(text('overview-wide-wide_protocol_rates-h3-value'),'1.0/s');
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'5.0/s');
assert.equal(text('overview-wide-wide_cache_activity-misses-value'),'4.0/s');
assert.equal(text('overview-wide-wide_connections-open-value'),'9');
now = 4000;
render({...second,metrics:{...second.metrics,http1_requests:0,http2_requests:0,http3_requests:0},cache:{...second.cache,hits:0,misses:0}});
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'-', 'counter resets must not create negative traffic');
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'-');
nodes.get('wide_cpu').hidden=true;
now=5000;render({...second, system:{}});
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%', 'hidden chart should not re-render');
nodes.get('wide_cpu').hidden=false;
now=6000;render({metrics:{},system:{},cache:{}});
assert.equal(text('overview-wide-wide_cpu-system-value'),'-', 'unavailable metrics are not zero');
assert.equal(text('overview-wide-wide_backend_health-up-value'),'-', 'missing backend array is no data');
ctx.window.rustProxyWideOverview.reset();
for(let i=0;i<75;i++){now+=1000;render({...baseline,system:{...baseline.system,cpu_percent:i}});}
assert(source.includes('const limit = 60'));
assert(source.includes('if (!node || node.hidden)'));
assert(!/fetch\s*\(|setInterval\s*\(/.test(source),'no extra API calls or polling');
const css=fs.readFileSync('src/admin/web/style.css','utf8');
const aligned = fs.readFileSync('src/admin/web/app/overview-masonry.js', 'utf8');
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-rows:\s*auto;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-flow:\s*row;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*gap:\s*var\(--ui-gap\);/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
assert.match(css, /#panel-overview\s*\{\s*container:\s*overview-canvas \/ inline-size;/);
assert.match(css, /@container overview-canvas \(max-width:\s*700px\)/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\.overview-row-start\s*\{[^}]*grid-column-start:\s*1;/);
assert.match(aligned, /node\.classList\.remove\("overview-row-start"\)/);
assert.match(aligned, /node\.classList\.add\("overview-row-start"\)/);
assert.match(aligned, /grid\.classList\.remove\("is-packed"\)/);
assert(!/style\.position\s*=\s*["']absolute["']/.test(aligned));
assert(!aligned.includes('fetch(') && !aligned.includes('setInterval('));
console.log('PASS: 7 optional wide charts, regular rows, no uneven masonry');
+3 -3
View File
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
ROOT = Path(__file__).resolve().parents[2]
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.2'
assert 'name = "rust-proxy"\nversion = "0.32.2"' in read('Cargo.lock')
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.2'
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.3'
assert 'name = "rust-proxy"\nversion = "0.32.3"' in read('Cargo.lock')
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.3'
pages = read('src/pages.rs')
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
+5 -5
View File
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.32.2"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert 'version = "0.32.3"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
assert (target / "langs/site.json").is_file()
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert (target / "docker-agent/certificate.go").is_file()
assert (target / "docker-agent/server.go").is_file()
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
assert manifest["to_version"] == "0.32.2"
assert manifest["to_version"] == "0.32.3"
assert manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists()
assert not (backup / "legacy-data").exists()
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
assert marker in admin, marker
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
assert '"/api/status"' not in anonymous_block
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.2"' in openapi_0241
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.3"' in openapi_0241
assert 'id="panel-logging"' not in html
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
assert marker in html, marker
@@ -969,7 +969,7 @@ def source_contract_smoke() -> None:
assert '.route("/404-hosts", get(public::index))' in admin and '"/assets/host404.js"' in admin_assets and 'include_str!("web/host404.js")' in admin_assets and '"not_found_hosts"' in admin
assert "session_at_with_browser_lifetime" in admin and "session_lifetime_seconds" in admin
assert '/api/preferences' in admin and 'live_log_columns' in admin
assert 'default_admin_theme' in admin and '"dark" | "light"' in admin
assert 'default_admin_theme' in admin and '"dark" | "ultra_dark" | "light" | "cream"' in admin
html = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
assert 'id="ui-theme"' in html and 'html[data-theme="light"] {' in ui_css
assert 'body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"])' in ui_css
@@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None:
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
assert '"/api/config/history/restore"' in openapi
for marker in ['"version": "0.32.2"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
for marker in ['"version": "0.32.3"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
assert '"software_update"' in openapi and '"update_available"' in openapi
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
+2 -2
View File
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
const en = json('langs/en_US.json').translations;
const pl = json('langs/pl_pl.json').translations;
assert.match(manifest, /version = "0\.32\.2"/);
assert.match(manifest, /version = "0\.32\.3"/);
assert.match(lib, /pub mod user_agent;/);
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
assert.match(fileLogs, /aggregate_min_count/);
assert.match(fileLogs, /file-log-aggregate-copy/);
assert.equal(openapi.info.version, '0.32.2');
assert.equal(openapi.info.version, '0.32.3');
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
+1 -1
View File
@@ -36,7 +36,7 @@ for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"):
assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}"
for required in (
"src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js",
"src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js",
"src/admin/web/vhosts/core.js", "src/admin/web/vhosts/protocol.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js",
"docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go",
"docker-agent/certificate.go", "docker-agent/server.go",
"scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py",
+2 -1
View File
@@ -90,10 +90,11 @@ assert(ids.every(id => cards.get(id).hidden), 'only the Appearance switch hides
widget.reset();
// Reuse original Appearance normalization logic to protect 0.31.x saved layouts.
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
const definitions = preferencesSource.slice(preferencesSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), preferencesSource.indexOf('const CHART_STYLE_KEYS'));
const normalize = preferencesSource.slice(preferencesSource.indexOf('function normalizeDashboardWidgets'), preferencesSource.indexOf('function browserLanguage'));
const sandbox = {};
vm.runInNewContext(`${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
vm.runInNewContext(`${registryPrelude}\n${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
const oldOrder = ['requests', 'errors'];
const migratedOrder = [...sandbox.normalizeDashboardWidgets(oldOrder)];
const migratedHidden = [...sandbox.normalizeHiddenDashboardWidgets([], oldOrder)];
@@ -24,11 +24,12 @@ assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bm
assert.match(html, /id="stream-dashboard-traffic-detail"/);
assert.match(html, /id="stream-dashboard-rx-rate"/);
assert.match(html, /id="stream-dashboard-tx-rate"/);
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('class="overview-grid"')), /<article[^>]*tabindex="0"/,
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"')), /<article[^>]*tabindex="0"/,
'metric cards must not introduce redundant keyboard stops');
const cardIds = [...html.matchAll(/<article class="stat[^"]*" data-dashboard-widget="([^"]+)"/g)].map(m => m[1]);
assert.equal(cardIds.length, 16, 'existing Overview cards must be retained');
assert.equal(cardIds.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_')).length, 16, 'original Overview cards must be retained');
assert.equal(cardIds.length, 48, '30 existing metric/Stream/backend tiles and 18 optional small charts must be registered');
const cards = new Map(cardIds.map(id => [id, { dataset: { dashboardWidget: id }, hidden: id.startsWith('stream_') }]));
const grid = { querySelectorAll: () => [...cards.values()], append(node) { assert(cards.has(node.dataset.dashboardWidget)); } };
const elements = new Map();
@@ -53,15 +54,16 @@ const context = {
normalizeRefreshPreferences: v => v || {}, renderRefreshPreferences() {},
applyAdminTheme: v => v, applyAdminLanguage: v => v, applyAdminUiScale: v => v,
applyAdminTimeFormat: v => v, applyAdminChartStyle: v => v,
applyAdminButtonStyle: v => v, applyAdminTableDensity: v => v,
applyAdminButtonStyle: v => v, applyAdminCornerStyle: v => v, applyAdminTableDensity: v => v,
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
};
vm.createContext(context);
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
const constants = prefsSource.slice(prefsSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefsSource.indexOf('const CHART_STYLE_KEYS'));
const normalize = prefsSource.slice(prefsSource.indexOf('function normalizeDashboardWidgets'), prefsSource.indexOf('function browserLanguage'));
const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboardLayout'), prefsSource.indexOf('function dashboardWidgetLabel'));
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme('));
vm.runInContext(`${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
vm.runInContext(`${registryPrelude}\n${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
window.rustProxyPreferences = { current: () => context.getPrefs() };
vm.runInContext(streamSource, context);
@@ -97,8 +99,8 @@ assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
// Native title tooltips do not participate in layout or create horizontal scroll.
assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed');
assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(min\(180px, 100%\), 1fr\)\)/,
'settings card columns must fit even a narrower mobile container');
assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(min\(260px, 100%\), 1fr\)\)/,
'effective Appearance card columns must preserve a readable name and right-aligned arrows');
assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
'old popover DOM and associations must be removed');
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
@@ -114,7 +116,7 @@ context.CSS = { escape: value => value };
vm.runInContext(prefsSource.slice(prefsSource.indexOf('function dashboardWidgetLabel'),
prefsSource.indexOf('function refreshChartStylePalette')), context);
context.renderDashboardWidgetSettings();
assert.equal(widgetList.children.length, cardIds.length, 'all cards retain settings controls');
assert.equal(widgetList.children.length, 62, 'all cards and full-width panels retain settings controls');
for (const id of ids) {
const row = widgetList.children.find(item => item.dataset.dashboardWidgetSetting === id);
const button = row.children[0];
@@ -123,10 +125,12 @@ for (const id of ids) {
stream_backend_health: 'streamWidgetHealthDescription' })[id];
assert.equal(row.title, key, `${id} must have a localized browser tooltip`);
assert.equal(button.attributes['aria-description'], key, 'the tooltip must remain accessible');
assert.equal(button.children.length, 2, 'label must not contain an injected custom tooltip');
assert.equal(button.children.length, 2, 'Appearance shows only the visibility indicator and label');
assert.equal(row.children.length, 2, 'Appearance keeps badge metadata in the visual editor, not in each setting');
assert.equal(row.children[1].className, 'dashboard-widget-setting-actions', 'move actions remain on the right');
}
assert.equal(widgetList.children.filter(row => row.title).length, 4,
'only the four translated Stream descriptions have tooltip text');
assert.equal(widgetList.children.filter(row => row.title).length, 50,
'Stream, backend, performance and full-panel entries have translated descriptions');
// Editor-specific regressions: avoid the redundant enable hint and label the
// two distinct inheritance layers instead of saying "Inherit" everywhere.
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Offline source contracts for RustProxy 0.32.3 upstream protocols and card registry.
These assertions supplement, rather than replace, Rust ALPN, H2C and websocket
runtime tests. No code execution against a RustProxy server is implied.
"""
import json
import re
from pathlib import Path
root=Path(__file__).resolve().parents[2]
read=lambda name:(root/name).read_text(encoding='utf-8')
registry=json.loads(read('src/admin/web/dashboard-widgets.json'))
ids=[widget['id'] for widget in registry]
assert len(ids)==len(set(ids)) and len(ids)>=16
assert all(re.fullmatch(r'[a-z0-9_]+',id) for id in ids)
html=read('src/admin/web/index.html')
html_ids=re.findall(r'<article\b[^>]*\bdata-dashboard-widget="([a-z0-9_]+)"',html)
assert len(html_ids)==len(set(html_ids)), 'duplicate Overview widget element'
assert set(ids)==set(html_ids), f'Registry diverges from rendered Overview cards: {set(ids)^set(html_ids)}'
assert ids==html_ids, 'registry should follow the default displayed card order'
for language in ['en_US','pl_pl']:
translations=json.loads(read(f'langs/{language}.json'))['translations']
assert 'Auto' in translations['vhProtocolAuto']
assert 'H2' in translations['vhProtocolHttp2']
assert 'H2' in translations['vhProtocolH2Strict']
assert all(key in translations for key in (
'vhProtocolCompatibilityWarning', 'vhProtocolAutoHttpsDetails', 'vhProtocolH2HttpDetails',
'vhProtocolLegacyHttp2HttpsDetails', 'vhProtocolLegacyHttp2HttpDetails',
'backendWidgetModeCompat', 'backendWidgetModeStrict',
'backendWidgetRequests', 'backendWidgetCapacity'))
for widget in registry:
assert widget['label_key'] in translations, (language,widget['label_key'])
if widget['description_key'] is not None:
assert widget['description_key'] in translations
for widget in registry:
assert widget['element_selector']==f'[data-dashboard-widget="{widget["id"]}"]'
assert widget['data_source'] in ('status_metrics','stream_status','backend_status','system_status','cache_status')
assert widget['no_data']=='neutral_dash'
assert isinstance(widget['default_hidden'],bool)
assert widget['category'] in ('overview','streams','backends','performance','panels')
assert {v['id'] for v in registry if v['default_hidden']}=={
'stream_overview','stream_traffic','stream_sessions','stream_backend_health',
'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',
'extra_process_cpu','extra_process_rss','extra_process_fds','extra_open_connections',
'extra_websocket_active','extra_tls_error_rate','extra_request_error_rate',
'extra_cache_hit_rate','extra_cache_memory','extra_h2_share','extra_response_latency',
'extra_backend_unhealthy',
'extra_total_rps', 'extra_active_exchanges', 'extra_h1_share', 'extra_h3_share',
'extra_cache_miss_rate', 'extra_backend_active',
'wide_protocol_rates', 'wide_cpu', 'wide_memory', 'wide_cache_activity',
'wide_connections', 'wide_backend_health', 'wide_latency'
}
backend=read('src/config/model.rs')
client=read('src/upstream/client.rs')
# Existing saved `http2` is NOT strict on HTTPS: only new `h2` is.
assert re.search(r'enum UpstreamProtocol\s*\{\s*#\[default\]\s*Http1,',backend)
for name in ['Http2,', 'Auto,', 'H2,']:
assert name in backend
assert 'is_https && backend.protocol == UpstreamProtocol::Http2' in client
assert 'let protocol = if websocket {' in client and 'UpstreamProtocol::Http1' in client
assert 'if !is_https && protocol == UpstreamProtocol::Auto' in client
assert 'UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http)' in client
assert 'UpstreamProtocol::Http2 | UpstreamProtocol::H2 => builder.enable_http2().wrap_connector(http)' in client
assert 'UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http)' in client
assert '.http2_only(matches!(protocol, UpstreamProtocol::Http2 | UpstreamProtocol::H2))' in client
assert 'backend.origin.starts_with("http://")' in read('src/proxy/types.rs')
assert 'UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2' in read('src/proxy/forward/upstream.rs')
assert '.retry_canceled_requests(false)' in client
assert '.pool_idle_timeout(' in client and '.pool_max_idle_per_host(' in client
assert 'generation_requests' in client and 'rotating' in client
assert 'include!("protocol_tests.rs");' in read('src/upstream/tests.rs')
protocol_tests=read('src/upstream/protocol_tests.rs')
for test in ['https_auto_chooses_h2_by_alpn_without_replaying_request',
'https_auto_negotiates_http1_before_sending_to_h1_only_peer',
'saved_legacy_https_http2_keeps_h1_fallback',
'saved_legacy_https_http2_still_prefers_h2',
'explicit_https_h2_requires_h2_and_refuses_h1_only_peer',
'explicit_https_h2_selects_h2_via_alpn',
'explicit_https_http1_does_not_offer_h2_to_h2_capable_peer',
'websocket_isolated_client_stays_on_http1_even_with_h2_backend',
'cleartext_auto_does_not_attempt_unsafe_h2c_probe',
'default_protocol_and_serialization_are_backward_compatible']:
assert f'fn {test}' in protocol_tests, test
assert 'fn explicit_h2c_backend_uses_http2_prior_knowledge' in read('src/upstream/tests.rs')
assert 'fn upstream_request_version(' in read('src/proxy/types.rs')
assert 'fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required' in read('src/proxy/tests/authority_tests.rs')
server=read('src/admin/preferences_columns.rs')
api=read('src/admin/preferences.rs')
router=read('src/admin/router.rs')
js=read('src/admin/web/app/preferences.js')
assert 'include_str!("web/dashboard-widgets.json")' in server
assert 'include_str!("web/dashboard-widgets.json")' in router
assert 'const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(' in router
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id)' in js
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden)' in js
assert 'ADMIN_DASHBOARD_BY_ID.get(id)?.description_key' in js
assert 'reconcile_dashboard_widgets(&mut preferences);' in api
assert 'prefs.dashboard_widgets.retain(' in api and 'prefs.dashboard_hidden_widgets.retain(' in api
assert 'fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids' in read('src/admin/preferences_tests.rs')
assert 'include!("preferences_tests.rs");' in read('src/admin/tests.rs')
assert 'item.insert("protocol".into(), json!(m.definition.protocol));' in read('src/balance/pool_view.rs')
assert 'include_str!("web/app/backend-dashboard.js")' in router
assert 'window.rustProxyBackendDashboard?.render(s)' in read('src/admin/web/app/core.js')
assert 'window.rustProxyBackendDashboard?.syncVisibility' in js
assert 'window.rustProxyBackendDashboard?.reset()' in read('src/admin/web/app/core.js')
assert 'vhProtocolCompatibilityWarning' in read('src/admin/web/vhosts/protocol.js')
assert '["h2", "vhProtocolH2Strict"]' in read('src/admin/web/vhosts/core.js')
assert 'backendWidgetRequestsTotal' in read('src/admin/web/app/backend-dashboard.js')
assert 'backendWidgetCapacityLimits' in read('src/admin/web/app/backend-dashboard.js')
assert 'include_str!("web/app/dashboard-layout-editor.js")' in router
assert 'include_str!("web/app/dashboard-inline-layout.js")' in router
assert all(widget.get('display_type') in {'value','value_chart','chart','panel','table'} for widget in registry)
assert 'dashboardWidgetTypeBadge' in js
assert 'overview-layout-unlock' in html and 'overview-layout-type-filter' in html
assert 'dashboard_widgets: [...draft.order]' in read('src/admin/web/app/dashboard-layout-editor.js')
assert 'dashboard_hidden_widgets: [...draft.hidden]' in read('src/admin/web/app/dashboard-layout-editor.js')
assert 'overview-layout-dialog' in html and 'overview-layout-open' in html
assert 'tls_errors_total' in read('src/admin/web/app/backend-dashboard.js')
assert len(registry) == 62
assert len([w for w in registry if w['category'] == 'panels']) == 14
assert 'include_str!("web/app/overview-extra-metrics.js")' in router
assert 'include_str!("web/app/overview-wide-metrics.js")' in router
assert 'include_str!("web/app/overview-masonry.js")' in router
assert 'window.rustProxyExtraOverview?.render(s)' in read('src/admin/web/app/core.js')
assert all(f'id="{id}"' in html for id in ('dashboard-overview-grid','overview-layout-default','overview-layout-reset','overview-reset-confirm'))
openapi = json.loads(read('src/admin/openapi.json'))
assert openapi['components']['schemas']['VhostBackend']['properties']['protocol']['enum'] == ['http1', 'http2', 'auto', 'h2']
prefs_schema = openapi['components']['schemas']['AdminPreferences']['properties']
for field in ('dashboard_widgets', 'dashboard_hidden_widgets'):
schema = prefs_schema[field]
assert schema['maxItems'] == len(registry), field
assert schema['items']['enum'] == ids, field
expected = ids if field == 'dashboard_widgets' else [widget['id'] for widget in registry if widget['default_hidden']]
assert schema['default'] == expected, field
assert '"version": "0.32.3"' in read('src/admin/openapi.json')
assert 'version = "0.32.3"' in read('Cargo.toml')
assert 'version = "0.32.3"' in read('Cargo.lock')
print(f'PASS: 0.32.3 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards')
+11 -3
View File
@@ -1,13 +1,13 @@
from pathlib import Path
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "preferences.js", "config-history.js", "runtime.js", "settings.js")
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "preferences.js", "dashboard-layout-editor.js", "dashboard-inline-layout.js", "stream-dashboard.js", "backend-dashboard.js", "config-history.js", "runtime.js", "settings.js")
PANEL_PARTS = (
"management.js", "drafts.js", "integrations.js", "vhosts.js",
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js",
"redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js",
"transform-panel.js", "acme.js", "settings.js", "certificate-api.js", "file-logs.js",
)
VHOST_PARTS = ("core.js", "health.js", "rates.js", "editor.js", "render.js")
VHOST_PARTS = ("core.js", "protocol.js", "health.js", "rates.js", "editor.js", "render.js")
def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str:
return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts)
@@ -17,7 +17,15 @@ def read_vhosts_js(root: Path) -> str:
return "(() => {\n" + body + "\n})();"
def read_app_js(root: Path) -> str:
primary = _read(root, "app", APP_PARTS)
# Mirror the Rust include_str! assembly including the server-validated
# widget registry, so QA parses the same core used by the real panel.
parts = []
for part in APP_PARTS:
if part == "preferences.js":
registry = (root / "src/admin/web/dashboard-widgets.json").read_text(encoding="utf-8")
parts.append(f"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({registry});")
parts.append((root / "src/admin/web/app" / part).read_text(encoding="utf-8"))
primary = "\n".join(parts)
panel = "\n".join(
read_vhosts_js(root) if name == "vhosts.js" else (root / "src/admin/web" / name).read_text(encoding="utf-8")
for name in PANEL_PARTS