v0.32.2
This commit is contained in:
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');
|
||||
Executable
+75
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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') };
|
||||
|
||||
@@ -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).')
|
||||
@@ -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');
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
@@ -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
@@ -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');
|
||||
@@ -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):
|
||||
|
||||
@@ -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"']:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
Executable
+147
@@ -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')
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user